mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-21 13:12:06 +02:00
19.0 vanilla
This commit is contained in:
parent
0a7ae8db93
commit
991d2234ca
416 changed files with 646602 additions and 300844 deletions
|
|
@ -1,25 +1,25 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
# ruff: noqa: F401
|
||||
|
||||
""" Modules (also called addons) management.
|
||||
|
||||
"""
|
||||
import odoo.init # import first for core setup
|
||||
|
||||
from . import db, graph, loading, migration, module, registry, neutralize
|
||||
from . import db # used directly during some migration scripts
|
||||
|
||||
from odoo.modules.loading import load_modules, reset_modules_state
|
||||
|
||||
from odoo.modules.module import (
|
||||
from . import module
|
||||
from .module import (
|
||||
Manifest,
|
||||
adapt_version,
|
||||
check_manifest_dependencies,
|
||||
get_module_path,
|
||||
get_module_resource,
|
||||
get_modules,
|
||||
get_modules_with_version,
|
||||
get_resource_from_path,
|
||||
get_resource_path,
|
||||
check_resource_path,
|
||||
initialize_sys_path,
|
||||
get_manifest,
|
||||
load_openerp_module,
|
||||
load_script
|
||||
)
|
||||
|
||||
from . import registry
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
""" Initialize the database for module management and Odoo installation. """
|
||||
from __future__ import annotations
|
||||
|
||||
from psycopg2.extras import Json
|
||||
import logging
|
||||
import typing
|
||||
from enum import IntEnum
|
||||
|
||||
from psycopg2.extras import Json
|
||||
|
||||
import odoo.modules
|
||||
import odoo.tools
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from odoo.sql_db import BaseCursor, Cursor
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def is_initialized(cr):
|
||||
|
||||
def is_initialized(cr: Cursor) -> bool:
|
||||
""" Check if a database has been initialized for the ORM.
|
||||
|
||||
The database can be initialized with the 'initialize' function below.
|
||||
|
|
@ -17,7 +25,8 @@ def is_initialized(cr):
|
|||
"""
|
||||
return odoo.tools.sql.table_exists(cr, 'ir_module_module')
|
||||
|
||||
def initialize(cr):
|
||||
|
||||
def initialize(cr: Cursor) -> None:
|
||||
""" Initialize a database with for the ORM.
|
||||
|
||||
This executes base/data/base_data.sql, creates the ir_module_categories
|
||||
|
|
@ -30,21 +39,13 @@ def initialize(cr):
|
|||
except FileNotFoundError:
|
||||
m = "File not found: 'base.sql' (provided by module 'base')."
|
||||
_logger.critical(m)
|
||||
raise IOError(m)
|
||||
raise OSError(m)
|
||||
|
||||
with odoo.tools.misc.file_open(f) as base_sql_file:
|
||||
cr.execute(base_sql_file.read()) # pylint: disable=sql-injection
|
||||
|
||||
for i in odoo.modules.get_modules():
|
||||
mod_path = odoo.modules.get_module_path(i)
|
||||
if not mod_path:
|
||||
continue
|
||||
|
||||
# This will raise an exception if no/unreadable descriptor file.
|
||||
info = odoo.modules.get_manifest(i)
|
||||
|
||||
if not info:
|
||||
continue
|
||||
for info in odoo.modules.Manifest.all_addon_manifests():
|
||||
module_name = info.name
|
||||
categories = info['category'].split('/')
|
||||
category_id = create_categories(cr, categories)
|
||||
|
||||
|
|
@ -58,23 +59,27 @@ def initialize(cr):
|
|||
category_id, auto_install, state, web, license, application, icon, sequence, summary) \
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id', (
|
||||
info['author'],
|
||||
info['website'], i, Json({'en_US': info['name']}),
|
||||
info['website'], module_name, Json({'en_US': info['name']}),
|
||||
Json({'en_US': info['description']}), category_id,
|
||||
info['auto_install'] is not False, state,
|
||||
info['web'],
|
||||
info['license'],
|
||||
info['application'], info['icon'],
|
||||
info['sequence'], Json({'en_US': info['summary']})))
|
||||
id = cr.fetchone()[0]
|
||||
cr.execute('INSERT INTO ir_model_data \
|
||||
(name,model,module, res_id, noupdate) VALUES (%s,%s,%s,%s,%s)', (
|
||||
'module_'+i, 'ir.module.module', 'base', id, True))
|
||||
row = cr.fetchone()
|
||||
assert row is not None # for typing
|
||||
module_id = row[0]
|
||||
cr.execute(
|
||||
'INSERT INTO ir_model_data'
|
||||
'(name,model,module, res_id, noupdate) VALUES (%s,%s,%s,%s,%s)',
|
||||
('module_' + module_name, 'ir.module.module', 'base', module_id, True),
|
||||
)
|
||||
dependencies = info['depends']
|
||||
for d in dependencies:
|
||||
cr.execute(
|
||||
'INSERT INTO ir_module_module_dependency (module_id, name, auto_install_required)'
|
||||
' VALUES (%s, %s, %s)',
|
||||
(id, d, d in (info['auto_install'] or ()))
|
||||
(module_id, d, d in (info['auto_install'] or ()))
|
||||
)
|
||||
|
||||
# Install recursively all auto-installing modules
|
||||
|
|
@ -106,10 +111,12 @@ def initialize(cr):
|
|||
""", [to_auto_install, to_auto_install])
|
||||
to_auto_install.extend(x[0] for x in cr.fetchall())
|
||||
|
||||
if not to_auto_install: break
|
||||
if not to_auto_install:
|
||||
break
|
||||
cr.execute("""UPDATE ir_module_module SET state='to install' WHERE name in %s""", (tuple(to_auto_install),))
|
||||
|
||||
def create_categories(cr, categories):
|
||||
|
||||
def create_categories(cr: Cursor, categories: list[str]) -> int | None:
|
||||
""" Create the ir_module_category entries for some categories.
|
||||
|
||||
categories is a list of strings forming a single category with its
|
||||
|
|
@ -127,26 +134,30 @@ def create_categories(cr, categories):
|
|||
cr.execute("SELECT res_id FROM ir_model_data WHERE name=%s AND module=%s AND model=%s",
|
||||
(xml_id, "base", "ir.module.category"))
|
||||
|
||||
c_id = cr.fetchone()
|
||||
if not c_id:
|
||||
row = cr.fetchone()
|
||||
if not row:
|
||||
cr.execute('INSERT INTO ir_module_category \
|
||||
(name, parent_id) \
|
||||
VALUES (%s, %s) RETURNING id', (Json({'en_US': categories[0]}), p_id))
|
||||
c_id = cr.fetchone()[0]
|
||||
row = cr.fetchone()
|
||||
assert row is not None # for typing
|
||||
p_id = row[0]
|
||||
cr.execute('INSERT INTO ir_model_data (module, name, res_id, model, noupdate) \
|
||||
VALUES (%s, %s, %s, %s, %s)', ('base', xml_id, c_id, 'ir.module.category', True))
|
||||
VALUES (%s, %s, %s, %s, %s)', ('base', xml_id, p_id, 'ir.module.category', True))
|
||||
else:
|
||||
c_id = c_id[0]
|
||||
p_id = c_id
|
||||
p_id = row[0]
|
||||
assert isinstance(p_id, int)
|
||||
categories = categories[1:]
|
||||
return p_id
|
||||
|
||||
|
||||
class FunctionStatus(IntEnum):
|
||||
MISSING = 0 # function is not present (falsy)
|
||||
PRESENT = 1 # function is present but not indexable (not immutable)
|
||||
INDEXABLE = 2 # function is present and indexable (immutable)
|
||||
|
||||
def has_unaccent(cr):
|
||||
|
||||
def has_unaccent(cr: BaseCursor) -> FunctionStatus:
|
||||
""" Test whether the database has function 'unaccent' and return its status.
|
||||
|
||||
The unaccent is supposed to be provided by the PostgreSQL unaccent contrib
|
||||
|
|
@ -170,7 +181,8 @@ def has_unaccent(cr):
|
|||
# https://www.postgresql.org/docs/current/catalog-pg-proc.html.
|
||||
return FunctionStatus.INDEXABLE if result[0] == 'i' else FunctionStatus.PRESENT
|
||||
|
||||
def has_trigram(cr):
|
||||
|
||||
def has_trigram(cr: BaseCursor) -> bool:
|
||||
""" Test if the database has the a word_similarity function.
|
||||
|
||||
The word_similarity is supposed to be provided by the PostgreSQL built-in
|
||||
|
|
|
|||
|
|
@ -1,199 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
""" Modules dependency graph. """
|
||||
|
||||
import functools
|
||||
import itertools
|
||||
import logging
|
||||
|
||||
import odoo
|
||||
import odoo.tools as tools
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _ignored_modules(cr):
|
||||
result = ['studio_customization']
|
||||
if tools.sql.column_exists(cr, 'ir_module_module', 'imported'):
|
||||
cr.execute('SELECT name FROM ir_module_module WHERE imported')
|
||||
result += [m[0] for m in cr.fetchall()]
|
||||
return result
|
||||
|
||||
class Graph(dict):
|
||||
""" Modules dependency graph.
|
||||
|
||||
The graph is a mapping from module name to Nodes.
|
||||
|
||||
"""
|
||||
|
||||
def add_node(self, name, info):
|
||||
max_depth, father = 0, None
|
||||
for d in info['depends']:
|
||||
n = self.get(d) or Node(d, self, None) # lazy creation, do not use default value for get()
|
||||
if n.depth >= max_depth:
|
||||
father = n
|
||||
max_depth = n.depth
|
||||
if father:
|
||||
return father.add_child(name, info)
|
||||
else:
|
||||
return Node(name, self, info)
|
||||
|
||||
def update_from_db(self, cr):
|
||||
if not len(self):
|
||||
return
|
||||
# update the graph with values from the database (if exist)
|
||||
## First, we set the default values for each package in graph
|
||||
additional_data = {key: {'id': 0, 'state': 'uninstalled', 'dbdemo': False, 'installed_version': None} for key in self.keys()}
|
||||
## Then we get the values from the database
|
||||
cr.execute('SELECT name, id, state, demo AS dbdemo, latest_version AS installed_version'
|
||||
' FROM ir_module_module'
|
||||
' WHERE name IN %s',(tuple(additional_data),)
|
||||
)
|
||||
|
||||
## and we update the default values with values from the database
|
||||
additional_data.update((x['name'], x) for x in cr.dictfetchall())
|
||||
|
||||
for package in self.values():
|
||||
for k, v in additional_data[package.name].items():
|
||||
setattr(package, k, v)
|
||||
|
||||
def add_module(self, cr, module, force=None):
|
||||
self.add_modules(cr, [module], force)
|
||||
|
||||
def add_modules(self, cr, module_list, force=None):
|
||||
if force is None:
|
||||
force = []
|
||||
packages = []
|
||||
len_graph = len(self)
|
||||
for module in module_list:
|
||||
info = odoo.modules.module.get_manifest(module)
|
||||
if info and info['installable']:
|
||||
packages.append((module, info)) # TODO directly a dict, like in get_modules_with_version
|
||||
elif module not in _ignored_modules(cr):
|
||||
_logger.warning('module %s: not installable, skipped', module)
|
||||
|
||||
dependencies = dict([(p, info['depends']) for p, info in packages])
|
||||
current, later = set([p for p, info in packages]), set()
|
||||
|
||||
while packages and current > later:
|
||||
package, info = packages[0]
|
||||
deps = info['depends']
|
||||
|
||||
# if all dependencies of 'package' are already in the graph, add 'package' in the graph
|
||||
if all(dep in self for dep in deps):
|
||||
if not package in current:
|
||||
packages.pop(0)
|
||||
continue
|
||||
later.clear()
|
||||
current.remove(package)
|
||||
node = self.add_node(package, info)
|
||||
for kind in ('init', 'demo', 'update'):
|
||||
if package in tools.config[kind] or 'all' in tools.config[kind] or kind in force:
|
||||
setattr(node, kind, True)
|
||||
else:
|
||||
later.add(package)
|
||||
packages.append((package, info))
|
||||
packages.pop(0)
|
||||
|
||||
self.update_from_db(cr)
|
||||
|
||||
for package in later:
|
||||
unmet_deps = [p for p in dependencies[package] if p not in self]
|
||||
_logger.info('module %s: Unmet dependencies: %s', package, ', '.join(unmet_deps))
|
||||
|
||||
return len(self) - len_graph
|
||||
|
||||
|
||||
def __iter__(self):
|
||||
level = 0
|
||||
done = set(self.keys())
|
||||
while done:
|
||||
level_modules = sorted((name, module) for name, module in self.items() if module.depth==level)
|
||||
for name, module in level_modules:
|
||||
done.remove(name)
|
||||
yield module
|
||||
level += 1
|
||||
|
||||
def __str__(self):
|
||||
return '\n'.join(str(n) for n in self if n.depth == 0)
|
||||
|
||||
class Node(object):
|
||||
""" One module in the modules dependency graph.
|
||||
|
||||
Node acts as a per-module singleton. A node is constructed via
|
||||
Graph.add_module() or Graph.add_modules(). Some of its fields are from
|
||||
ir_module_module (set by Graph.update_from_db()).
|
||||
|
||||
"""
|
||||
def __new__(cls, name, graph, info):
|
||||
if name in graph:
|
||||
inst = graph[name]
|
||||
else:
|
||||
inst = object.__new__(cls)
|
||||
graph[name] = inst
|
||||
return inst
|
||||
|
||||
def __init__(self, name, graph, info):
|
||||
self.name = name
|
||||
self.graph = graph
|
||||
self.info = info or getattr(self, 'info', {})
|
||||
if not hasattr(self, 'children'):
|
||||
self.children = []
|
||||
if not hasattr(self, 'depth'):
|
||||
self.depth = 0
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self.info
|
||||
|
||||
def add_child(self, name, info):
|
||||
node = Node(name, self.graph, info)
|
||||
node.depth = self.depth + 1
|
||||
if node not in self.children:
|
||||
self.children.append(node)
|
||||
for attr in ('init', 'update', 'demo'):
|
||||
if hasattr(self, attr):
|
||||
setattr(node, attr, True)
|
||||
self.children.sort(key=lambda x: x.name)
|
||||
return node
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
super(Node, self).__setattr__(name, value)
|
||||
if name in ('init', 'update', 'demo'):
|
||||
tools.config[name][self.name] = 1
|
||||
for child in self.children:
|
||||
setattr(child, name, value)
|
||||
if name == 'depth':
|
||||
for child in self.children:
|
||||
setattr(child, name, value + 1)
|
||||
|
||||
def __iter__(self):
|
||||
return itertools.chain(
|
||||
self.children,
|
||||
itertools.chain.from_iterable(self.children)
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self._pprint()
|
||||
|
||||
def _pprint(self, depth=0):
|
||||
s = '%s\n' % self.name
|
||||
for c in self.children:
|
||||
s += '%s`-> %s' % (' ' * depth, c._pprint(depth+1))
|
||||
return s
|
||||
|
||||
def should_have_demo(self):
|
||||
return (hasattr(self, 'demo') or (self.dbdemo and self.state != 'installed')) and all(p.dbdemo for p in self.parents)
|
||||
|
||||
@property
|
||||
def parents(self):
|
||||
if self.depth == 0:
|
||||
return []
|
||||
|
||||
return (
|
||||
node for node in self.graph.values()
|
||||
if node.depth < self.depth
|
||||
if self in node.children
|
||||
)
|
||||
|
|
@ -1,90 +1,72 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
""" Modules (also called addons) management.
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import itertools
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
import traceback
|
||||
|
||||
import odoo
|
||||
import odoo.modules.db
|
||||
import odoo.modules.graph
|
||||
import odoo.modules.migration
|
||||
import odoo.modules.registry
|
||||
from .. import SUPERUSER_ID, api, tools
|
||||
import odoo.sql_db
|
||||
import odoo.tools.sql
|
||||
import odoo.tools.translate
|
||||
from odoo import api, tools
|
||||
from odoo.tools.convert import convert_file, IdRef, ConvertMode as LoadMode
|
||||
|
||||
from . import db as modules_db
|
||||
from .migration import MigrationManager
|
||||
from .module import adapt_version, initialize_sys_path, load_openerp_module
|
||||
from .module_graph import ModuleGraph
|
||||
from .registry import Registry
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from collections.abc import Collection, Iterable
|
||||
from odoo.api import Environment
|
||||
from odoo.sql_db import BaseCursor
|
||||
from odoo.tests.result import OdooTestResult
|
||||
from .module_graph import ModuleNode
|
||||
|
||||
LoadKind = typing.Literal['data', 'demo']
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_data(env, idref, mode, kind, package):
|
||||
def load_data(env: Environment, idref: IdRef, mode: LoadMode, kind: LoadKind, package: ModuleNode) -> bool:
|
||||
"""
|
||||
|
||||
kind: data, demo, test, init_xml, update_xml, demo_xml.
|
||||
|
||||
noupdate is False, unless it is demo data or it is csv data in
|
||||
init mode.
|
||||
noupdate is False, unless it is demo data
|
||||
|
||||
:returns: Whether a file was loaded
|
||||
:rtype: bool
|
||||
"""
|
||||
keys = ('init_xml', 'data') if kind == 'data' else ('demo',)
|
||||
|
||||
def _get_files_of_kind(kind):
|
||||
if kind == 'demo':
|
||||
keys = ['demo_xml', 'demo']
|
||||
elif kind == 'data':
|
||||
keys = ['init_xml', 'update_xml', 'data']
|
||||
if isinstance(kind, str):
|
||||
keys = [kind]
|
||||
files = []
|
||||
for k in keys:
|
||||
for f in package.data[k]:
|
||||
if f in files:
|
||||
_logger.warning("File %s is imported twice in module %s %s", f, package.name, kind)
|
||||
files.append(f)
|
||||
if k.endswith('_xml') and not (k == 'init_xml' and not f.endswith('.xml')):
|
||||
# init_xml, update_xml and demo_xml are deprecated except
|
||||
# for the case of init_xml with csv and sql files as
|
||||
# we can't specify noupdate for those file.
|
||||
correct_key = 'demo' if k.count('demo') else 'data'
|
||||
_logger.warning(
|
||||
"module %s: key '%s' is deprecated in favor of '%s' for file '%s'.",
|
||||
package.name, k, correct_key, f
|
||||
)
|
||||
return files
|
||||
files: set[str] = set()
|
||||
for k in keys:
|
||||
if k == 'init_xml' and package.manifest[k]:
|
||||
_logger.warning("module %s: key 'init_xml' is deprecated in Odoo 19.", package.name)
|
||||
for filename in package.manifest[k]:
|
||||
if filename in files:
|
||||
_logger.warning("File %s is imported twice in module %s %s", filename, package.name, kind)
|
||||
files.add(filename)
|
||||
|
||||
filename = None
|
||||
try:
|
||||
if kind in ('demo', 'test'):
|
||||
threading.current_thread().testing = True
|
||||
for filename in _get_files_of_kind(kind):
|
||||
_logger.info("loading %s/%s", package.name, filename)
|
||||
noupdate = False
|
||||
if kind in ('demo', 'demo_xml') or (filename.endswith('.csv') and kind in ('init', 'init_xml')):
|
||||
noupdate = True
|
||||
tools.convert_file(env, package.name, filename, idref, mode, noupdate, kind)
|
||||
finally:
|
||||
if kind in ('demo', 'test'):
|
||||
threading.current_thread().testing = False
|
||||
convert_file(env, package.name, filename, idref, mode, noupdate=kind == 'demo')
|
||||
|
||||
return bool(filename)
|
||||
return bool(files)
|
||||
|
||||
def load_demo(env, package, idref, mode):
|
||||
|
||||
def load_demo(env: Environment, package: ModuleNode, idref: IdRef, mode: LoadMode) -> bool:
|
||||
"""
|
||||
Loads demo data for the specified package.
|
||||
"""
|
||||
if not package.should_have_demo():
|
||||
return False
|
||||
|
||||
try:
|
||||
if package.data.get('demo') or package.data.get('demo_xml'):
|
||||
if package.manifest.get('demo') or package.manifest.get('demo_xml'):
|
||||
_logger.info("Module %s: loading demo", package.name)
|
||||
with env.cr.savepoint(flush=False):
|
||||
load_data(env(su=True), idref, mode, kind='demo', package=package)
|
||||
|
|
@ -103,46 +85,47 @@ def load_demo(env, package, idref, mode):
|
|||
return False
|
||||
|
||||
|
||||
def force_demo(env):
|
||||
def force_demo(env: Environment) -> None:
|
||||
"""
|
||||
Forces the `demo` flag on all modules, and installs demo data for all installed modules.
|
||||
"""
|
||||
graph = odoo.modules.graph.Graph()
|
||||
env.cr.execute('UPDATE ir_module_module SET demo=True')
|
||||
env.cr.execute(
|
||||
"SELECT name FROM ir_module_module WHERE state IN ('installed', 'to upgrade', 'to remove')"
|
||||
)
|
||||
module_list = [name for (name,) in env.cr.fetchall()]
|
||||
graph.add_modules(env.cr, module_list, ['demo'])
|
||||
graph = ModuleGraph(env.cr, mode='load')
|
||||
graph.extend(module_list)
|
||||
|
||||
for package in graph:
|
||||
load_demo(env, package, {}, 'init')
|
||||
|
||||
env['ir.module.module'].invalidate_model(['demo'])
|
||||
env['res.groups']._update_user_groups_view()
|
||||
|
||||
|
||||
def load_module_graph(env, graph, status=None, perform_checks=True,
|
||||
skip_modules=None, report=None, models_to_check=None):
|
||||
"""Migrates+Updates or Installs all module nodes from ``graph``
|
||||
def load_module_graph(
|
||||
env: Environment,
|
||||
graph: ModuleGraph,
|
||||
update_module: bool = False,
|
||||
report: OdooTestResult | None = None,
|
||||
models_to_check: set[str] | None = None,
|
||||
install_demo: bool = True,
|
||||
) -> None:
|
||||
""" Load, upgrade and install not loaded module nodes in the ``graph`` for ``env.registry``
|
||||
|
||||
:param env:
|
||||
:param graph: graph of module nodes to load
|
||||
:param status: deprecated parameter, unused, left to avoid changing signature in 8.0
|
||||
:param perform_checks: whether module descriptors should be checked for validity (prints warnings
|
||||
for same cases)
|
||||
:param skip_modules: optional list of module names (packages) which have previously been loaded and can be skipped
|
||||
:param update_module: whether to update modules or not
|
||||
:param report:
|
||||
:param set models_to_check:
|
||||
:return: list of modules that were installed or updated
|
||||
:param install_demo: whether to attempt installing demo data for newly installed modules
|
||||
"""
|
||||
if models_to_check is None:
|
||||
models_to_check = set()
|
||||
|
||||
processed_modules = []
|
||||
loaded_modules = []
|
||||
registry = env.registry
|
||||
migrations = odoo.modules.migration.MigrationManager(env.cr, graph)
|
||||
assert isinstance(env.cr, odoo.sql_db.Cursor), "Need for a real Cursor to load modules"
|
||||
migrations = MigrationManager(env.cr, graph)
|
||||
module_count = len(graph)
|
||||
_logger.info('loading %d modules...', module_count)
|
||||
|
||||
|
|
@ -157,102 +140,98 @@ def load_module_graph(env, graph, status=None, perform_checks=True,
|
|||
module_name = package.name
|
||||
module_id = package.id
|
||||
|
||||
if skip_modules and module_name in skip_modules:
|
||||
if module_name in registry._init_modules:
|
||||
continue
|
||||
|
||||
module_t0 = time.time()
|
||||
module_cursor_query_count = env.cr.sql_log_count
|
||||
module_extra_query_count = odoo.sql_db.sql_counter
|
||||
|
||||
needs_update = (
|
||||
hasattr(package, "init")
|
||||
or hasattr(package, "update")
|
||||
or package.state in ("to install", "to upgrade")
|
||||
)
|
||||
update_operation = (
|
||||
'install' if package.state == 'to install' else
|
||||
'upgrade' if package.state == 'to upgrade' else
|
||||
'reinit' if module_name in registry._reinit_modules else
|
||||
None
|
||||
) if update_module else None
|
||||
module_log_level = logging.DEBUG
|
||||
if needs_update:
|
||||
if update_operation:
|
||||
module_log_level = logging.INFO
|
||||
_logger.log(module_log_level, 'Loading module %s (%d/%d)', module_name, index, module_count)
|
||||
|
||||
new_install = package.state == 'to install'
|
||||
if needs_update:
|
||||
if not new_install:
|
||||
if update_operation:
|
||||
if update_operation == 'upgrade' or module_name in registry._force_upgrade_scripts:
|
||||
if package.name != 'base':
|
||||
registry.setup_models(env.cr)
|
||||
registry._setup_models__(env.cr, []) # incremental setup
|
||||
migrations.migrate_module(package, 'pre')
|
||||
if package.name != 'base':
|
||||
env.flush_all()
|
||||
|
||||
load_openerp_module(package.name)
|
||||
|
||||
if new_install:
|
||||
if update_operation == 'install':
|
||||
py_module = sys.modules['odoo.addons.%s' % (module_name,)]
|
||||
pre_init = package.info.get('pre_init_hook')
|
||||
pre_init = package.manifest.get('pre_init_hook')
|
||||
if pre_init:
|
||||
registry.setup_models(env.cr)
|
||||
registry._setup_models__(env.cr, []) # incremental setup
|
||||
getattr(py_module, pre_init)(env)
|
||||
|
||||
model_names = registry.load(env.cr, package)
|
||||
model_names = registry.load(package)
|
||||
|
||||
mode = 'update'
|
||||
if hasattr(package, 'init') or package.state == 'to install':
|
||||
mode = 'init'
|
||||
|
||||
loaded_modules.append(package.name)
|
||||
if needs_update:
|
||||
if update_operation:
|
||||
model_names = registry.descendants(model_names, '_inherit', '_inherits')
|
||||
models_updated |= set(model_names)
|
||||
models_to_check -= set(model_names)
|
||||
registry.setup_models(env.cr)
|
||||
registry.init_models(env.cr, model_names, {'module': package.name}, new_install)
|
||||
elif package.state != 'to remove':
|
||||
registry._setup_models__(env.cr, []) # incremental setup
|
||||
registry.init_models(env.cr, model_names, {'module': package.name}, update_operation == 'install')
|
||||
elif update_module and package.state != 'to remove':
|
||||
# The current module has simply been loaded. The models extended by this module
|
||||
# and for which we updated the schema, must have their schema checked again.
|
||||
# This is because the extension may have changed the model,
|
||||
# e.g. adding required=True to an existing field, but the schema has not been
|
||||
# updated by this module because it's not marked as 'to upgrade/to install'.
|
||||
model_names = registry.descendants(model_names, '_inherit', '_inherits')
|
||||
models_to_check |= set(model_names) & models_updated
|
||||
|
||||
idref = {}
|
||||
|
||||
if needs_update:
|
||||
if update_operation:
|
||||
# Can't put this line out of the loop: ir.module.module will be
|
||||
# registered by init_models() above.
|
||||
module = env['ir.module.module'].browse(module_id)
|
||||
module._check()
|
||||
|
||||
if perform_checks:
|
||||
module._check()
|
||||
idref: dict = {}
|
||||
|
||||
if package.state == 'to upgrade':
|
||||
if update_operation == 'install':
|
||||
load_data(env, idref, 'init', kind='data', package=package)
|
||||
if install_demo and package.demo_installable:
|
||||
package.demo = load_demo(env, package, idref, 'init')
|
||||
else: # 'upgrade' or 'reinit'
|
||||
# upgrading the module information
|
||||
module.write(module.get_values_from_terp(package.data))
|
||||
load_data(env, idref, mode, kind='data', package=package)
|
||||
demo_loaded = package.dbdemo = load_demo(env, package, idref, mode)
|
||||
env.cr.execute('update ir_module_module set demo=%s where id=%s', (demo_loaded, module_id))
|
||||
module.write(module.get_values_from_terp(package.manifest))
|
||||
mode = 'update' if update_operation == 'upgrade' else 'init'
|
||||
load_data(env, idref, mode, kind='data', package=package)
|
||||
if package.demo:
|
||||
package.demo = load_demo(env, package, idref, mode)
|
||||
env.cr.execute('UPDATE ir_module_module SET demo = %s WHERE id = %s', (package.demo, module_id))
|
||||
module.invalidate_model(['demo'])
|
||||
|
||||
migrations.migrate_module(package, 'post')
|
||||
|
||||
# Update translations for all installed languages
|
||||
overwrite = odoo.tools.config["overwrite_existing_translations"]
|
||||
overwrite = tools.config["overwrite_existing_translations"]
|
||||
module._update_translations(overwrite=overwrite)
|
||||
|
||||
if package.name is not None:
|
||||
registry._init_modules.add(package.name)
|
||||
|
||||
if needs_update:
|
||||
if new_install:
|
||||
post_init = package.info.get('post_init_hook')
|
||||
if update_operation:
|
||||
if update_operation == 'install':
|
||||
post_init = package.manifest.get('post_init_hook')
|
||||
if post_init:
|
||||
getattr(py_module, post_init)(env)
|
||||
|
||||
if mode == 'update':
|
||||
elif update_operation == 'upgrade':
|
||||
# validate the views that have not been checked yet
|
||||
env['ir.ui.view']._validate_module_views(module_name)
|
||||
|
||||
# need to commit any modification the module's installation or
|
||||
# update made to the schema or data so the tests can run
|
||||
# (separately in their own transaction)
|
||||
env.cr.commit()
|
||||
concrete_models = [model for model in model_names if not registry[model]._abstract]
|
||||
if concrete_models:
|
||||
env.cr.execute("""
|
||||
|
|
@ -270,18 +249,32 @@ def load_module_graph(env, graph, status=None, perform_checks=True,
|
|||
lines.append(f"{module_name}.access_{xmlid},access_{xmlid},{module_name}.model_{xmlid},base.group_user,1,0,0,0")
|
||||
_logger.warning('\n'.join(lines))
|
||||
|
||||
updating = tools.config.options['init'] or tools.config.options['update']
|
||||
test_time = test_queries = 0
|
||||
registry.updated_modules.append(package.name)
|
||||
|
||||
ver = adapt_version(package.manifest['version'])
|
||||
# Set new modules and dependencies
|
||||
module.write({'state': 'installed', 'latest_version': ver})
|
||||
|
||||
package.state = 'installed'
|
||||
module.env.flush_all()
|
||||
module.env.cr.commit()
|
||||
|
||||
test_time = 0.0
|
||||
test_queries = 0
|
||||
test_results = None
|
||||
if tools.config.options['test_enable'] and (needs_update or not updating):
|
||||
|
||||
update_from_config = tools.config['update'] or tools.config['init'] or tools.config['reinit']
|
||||
if tools.config['test_enable'] and (update_operation or not update_from_config):
|
||||
from odoo.tests import loader # noqa: PLC0415
|
||||
suite = loader.make_suite([module_name], 'at_install')
|
||||
if suite.countTestCases():
|
||||
if not needs_update:
|
||||
registry.setup_models(env.cr)
|
||||
if not update_operation:
|
||||
registry._setup_models__(env.cr, []) # incremental setup
|
||||
registry.check_null_constraints(env.cr)
|
||||
# Python tests
|
||||
tests_t0, tests_q0 = time.time(), odoo.sql_db.sql_counter
|
||||
test_results = loader.run_suite(suite, global_report=report)
|
||||
assert report is not None, "Missing report during tests"
|
||||
report.update(test_results)
|
||||
test_time = time.time() - tests_t0
|
||||
test_queries = odoo.sql_db.sql_counter - tests_q0
|
||||
|
|
@ -289,20 +282,6 @@ def load_module_graph(env, graph, status=None, perform_checks=True,
|
|||
# tests may have reset the environment
|
||||
module = env['ir.module.module'].browse(module_id)
|
||||
|
||||
if needs_update:
|
||||
processed_modules.append(package.name)
|
||||
|
||||
ver = adapt_version(package.data['version'])
|
||||
# Set new modules and dependencies
|
||||
module.write({'state': 'installed', 'latest_version': ver})
|
||||
|
||||
package.load_state = package.state
|
||||
package.load_version = package.installed_version
|
||||
package.state = 'installed'
|
||||
for kind in ('init', 'demo', 'update'):
|
||||
if hasattr(package, kind):
|
||||
delattr(package, kind)
|
||||
module.env.flush_all()
|
||||
|
||||
extra_queries = odoo.sql_db.sql_counter - module_extra_query_count - test_queries
|
||||
extras = []
|
||||
|
|
@ -330,59 +309,43 @@ def load_module_graph(env, graph, status=None, perform_checks=True,
|
|||
env.cr.sql_log_count - loading_cursor_query_count,
|
||||
odoo.sql_db.sql_counter - loading_extra_query_count) # extra queries: testes, notify, any other closed cursor
|
||||
|
||||
return loaded_modules, processed_modules
|
||||
|
||||
def _check_module_names(cr, module_names):
|
||||
def _check_module_names(cr: BaseCursor, module_names: Iterable[str]) -> None:
|
||||
mod_names = set(module_names)
|
||||
if 'base' in mod_names:
|
||||
# ignore dummy 'all' module
|
||||
if 'all' in mod_names:
|
||||
mod_names.remove('all')
|
||||
mod_names.discard('all')
|
||||
if mod_names:
|
||||
cr.execute("SELECT count(id) AS count FROM ir_module_module WHERE name in %s", (tuple(mod_names),))
|
||||
if cr.dictfetchone()['count'] != len(mod_names):
|
||||
row = cr.fetchone()
|
||||
assert row is not None # for typing
|
||||
if row[0] != len(mod_names):
|
||||
# find out what module name(s) are incorrect:
|
||||
cr.execute("SELECT name FROM ir_module_module")
|
||||
incorrect_names = mod_names.difference([x['name'] for x in cr.dictfetchall()])
|
||||
_logger.warning('invalid module names, ignored: %s', ", ".join(incorrect_names))
|
||||
|
||||
def load_marked_modules(env, graph, states, force, progressdict, report,
|
||||
loaded_modules, perform_checks, models_to_check=None):
|
||||
"""Loads modules marked with ``states``, adding them to ``graph`` and
|
||||
``loaded_modules`` and returns a list of installed/upgraded modules."""
|
||||
|
||||
if models_to_check is None:
|
||||
models_to_check = set()
|
||||
|
||||
processed_modules = []
|
||||
while True:
|
||||
env.cr.execute("SELECT name from ir_module_module WHERE state IN %s", (tuple(states),))
|
||||
module_list = [name for (name,) in env.cr.fetchall() if name not in graph]
|
||||
if not module_list:
|
||||
break
|
||||
graph.add_modules(env.cr, module_list, force)
|
||||
_logger.debug('Updating graph with %d more modules', len(module_list))
|
||||
loaded, processed = load_module_graph(
|
||||
env, graph, progressdict, report=report, skip_modules=loaded_modules,
|
||||
perform_checks=perform_checks, models_to_check=models_to_check
|
||||
)
|
||||
processed_modules.extend(processed)
|
||||
loaded_modules.extend(loaded)
|
||||
if not processed:
|
||||
break
|
||||
return processed_modules
|
||||
|
||||
def load_modules(registry, force_demo=False, status=None, update_module=False):
|
||||
def load_modules(
|
||||
registry: Registry,
|
||||
*,
|
||||
update_module: bool = False,
|
||||
upgrade_modules: Collection[str] = (),
|
||||
install_modules: Collection[str] = (),
|
||||
reinit_modules: Collection[str] = (),
|
||||
new_db_demo: bool = False,
|
||||
) -> None:
|
||||
""" Load the modules for a registry object that has just been created. This
|
||||
function is part of Registry.new() and should not be used anywhere else.
|
||||
|
||||
:param registry: The new inited registry object used to load modules.
|
||||
:param update_module: Whether to update (install, upgrade, or uninstall) modules. Defaults to ``False``
|
||||
:param upgrade_modules: A collection of module names to upgrade.
|
||||
:param install_modules: A collection of module names to install.
|
||||
:param reinit_modules: A collection of module names to reinitialize.
|
||||
:param new_db_demo: Whether to install demo data for new database. Defaults to ``False``
|
||||
"""
|
||||
initialize_sys_path()
|
||||
|
||||
force = []
|
||||
if force_demo:
|
||||
force.append('demo')
|
||||
|
||||
models_to_check = set()
|
||||
models_to_check: set[str] = set()
|
||||
|
||||
with registry.cursor() as cr:
|
||||
# prevent endless wait for locks on schema changes (during online
|
||||
|
|
@ -390,52 +353,53 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
|
|||
# connection settings are automatically reset when the connection is
|
||||
# borrowed from the pool
|
||||
cr.execute("SET SESSION lock_timeout = '15s'")
|
||||
if not odoo.modules.db.is_initialized(cr):
|
||||
if not modules_db.is_initialized(cr):
|
||||
if not update_module:
|
||||
_logger.error("Database %s not initialized, you can force it with `-i base`", cr.dbname)
|
||||
return
|
||||
_logger.info("init db")
|
||||
odoo.modules.db.initialize(cr)
|
||||
update_module = True # process auto-installed modules
|
||||
tools.config["init"]["all"] = 1
|
||||
if not tools.config['without_demo']:
|
||||
tools.config["demo"]['all'] = 1
|
||||
_logger.info("Initializing database %s", cr.dbname)
|
||||
modules_db.initialize(cr)
|
||||
elif 'base' in reinit_modules:
|
||||
registry._reinit_modules.add('base')
|
||||
|
||||
if 'base' in tools.config['update'] or 'all' in tools.config['update']:
|
||||
if 'base' in upgrade_modules:
|
||||
cr.execute("update ir_module_module set state=%s where name=%s and state=%s", ('to upgrade', 'base', 'installed'))
|
||||
|
||||
# STEP 1: LOAD BASE (must be done before module dependencies can be computed for later steps)
|
||||
graph = odoo.modules.graph.Graph()
|
||||
graph.add_module(cr, 'base', force)
|
||||
graph = ModuleGraph(cr, mode='update' if update_module else 'load')
|
||||
graph.extend(['base'])
|
||||
if not graph:
|
||||
_logger.critical('module base cannot be loaded! (hint: verify addons-path)')
|
||||
raise ImportError('Module `base` cannot be loaded! (hint: verify addons-path)')
|
||||
if update_module and tools.config['update']:
|
||||
for pyfile in tools.config['pre_upgrade_scripts'].split(','):
|
||||
if update_module and upgrade_modules:
|
||||
for pyfile in tools.config['pre_upgrade_scripts']:
|
||||
odoo.modules.migration.exec_script(cr, graph['base'].installed_version, pyfile, 'base', 'pre')
|
||||
|
||||
if update_module and odoo.tools.sql.table_exists(cr, 'ir_model_fields'):
|
||||
if update_module and tools.sql.table_exists(cr, 'ir_model_fields'):
|
||||
# determine the fields which are currently translated in the database
|
||||
cr.execute("SELECT model || '.' || name FROM ir_model_fields WHERE translate IS TRUE")
|
||||
registry._database_translated_fields = {row[0] for row in cr.fetchall()}
|
||||
cr.execute("SELECT model || '.' || name, translate FROM ir_model_fields WHERE translate IS NOT NULL")
|
||||
registry._database_translated_fields = dict(cr.fetchall())
|
||||
|
||||
# determine the fields which are currently company dependent in the database
|
||||
if odoo.tools.sql.column_exists(cr, 'ir_model_fields', 'company_dependent'):
|
||||
cr.execute("SELECT model || '.' || name FROM ir_model_fields WHERE company_dependent IS TRUE")
|
||||
registry._database_company_dependent_fields = {row[0] for row in cr.fetchall()}
|
||||
|
||||
# processed_modules: for cleanup step after install
|
||||
# loaded_modules: to avoid double loading
|
||||
report = registry._assertion_report
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
loaded_modules, processed_modules = load_module_graph(
|
||||
env, graph, status, perform_checks=update_module,
|
||||
report=report, models_to_check=models_to_check)
|
||||
env = api.Environment(cr, api.SUPERUSER_ID, {})
|
||||
load_module_graph(
|
||||
env,
|
||||
graph,
|
||||
update_module=update_module,
|
||||
report=report,
|
||||
models_to_check=models_to_check,
|
||||
install_demo=new_db_demo,
|
||||
)
|
||||
|
||||
load_lang = tools.config.pop('load_language')
|
||||
load_lang = tools.config._cli_options.pop('load_language', None)
|
||||
if load_lang or update_module:
|
||||
# some base models are used below, so make sure they are set up
|
||||
registry.setup_models(cr)
|
||||
registry._setup_models__(cr, []) # incremental setup
|
||||
|
||||
if load_lang:
|
||||
for lang in load_lang.split(','):
|
||||
|
|
@ -447,54 +411,52 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
|
|||
_logger.info('updating modules list')
|
||||
Module.update_list()
|
||||
|
||||
_check_module_names(cr, itertools.chain(tools.config['init'], tools.config['update']))
|
||||
_check_module_names(cr, itertools.chain(install_modules, upgrade_modules))
|
||||
|
||||
module_names = [k for k, v in tools.config['init'].items() if v]
|
||||
if module_names:
|
||||
modules = Module.search([('state', '=', 'uninstalled'), ('name', 'in', module_names)])
|
||||
if install_modules:
|
||||
modules = Module.search([('state', '=', 'uninstalled'), ('name', 'in', tuple(install_modules))])
|
||||
if modules:
|
||||
modules.button_install()
|
||||
|
||||
module_names = [k for k, v in tools.config['update'].items() if v]
|
||||
if module_names:
|
||||
modules = Module.search([('state', 'in', ('installed', 'to upgrade')), ('name', 'in', module_names)])
|
||||
if upgrade_modules:
|
||||
modules = Module.search([('state', 'in', ('installed', 'to upgrade')), ('name', 'in', tuple(upgrade_modules))])
|
||||
if modules:
|
||||
modules.button_upgrade()
|
||||
|
||||
if reinit_modules:
|
||||
modules = Module.search([('state', 'in', ('installed', 'to upgrade')), ('name', 'in', tuple(reinit_modules))])
|
||||
reinit_modules = modules.downstream_dependencies(exclude_states=('uninstalled', 'uninstallable', 'to remove', 'to install')) + modules
|
||||
registry._reinit_modules.update(m for m in reinit_modules.mapped('name') if m not in graph._imported_modules)
|
||||
|
||||
env.flush_all()
|
||||
cr.execute("update ir_module_module set state=%s where name=%s", ('installed', 'base'))
|
||||
Module.invalidate_model(['state'])
|
||||
|
||||
# STEP 3: Load marked modules (skipping base which was done in STEP 1)
|
||||
# IMPORTANT: this is done in two parts, first loading all installed or
|
||||
# partially installed modules (i.e. installed/to upgrade), to
|
||||
# offer a consistent system to the second part: installing
|
||||
# newly selected modules.
|
||||
# We include the modules 'to remove' in the first step, because
|
||||
# they are part of the "currently installed" modules. They will
|
||||
# be dropped in STEP 6 later, before restarting the loading
|
||||
# process.
|
||||
# IMPORTANT 2: We have to loop here until all relevant modules have been
|
||||
# processed, because in some rare cases the dependencies have
|
||||
# changed, and modules that depend on an uninstalled module
|
||||
# will not be processed on the first pass.
|
||||
# It's especially useful for migrations.
|
||||
previously_processed = -1
|
||||
while previously_processed < len(processed_modules):
|
||||
previously_processed = len(processed_modules)
|
||||
processed_modules += load_marked_modules(env, graph,
|
||||
['installed', 'to upgrade', 'to remove'],
|
||||
force, status, report, loaded_modules, update_module, models_to_check)
|
||||
# loop this step in case extra modules' states are changed to 'to install'/'to update' during loading
|
||||
while True:
|
||||
if update_module:
|
||||
processed_modules += load_marked_modules(env, graph,
|
||||
['to install'], force, status, report,
|
||||
loaded_modules, update_module, models_to_check)
|
||||
states = ('installed', 'to upgrade', 'to remove', 'to install')
|
||||
else:
|
||||
states = ('installed', 'to upgrade', 'to remove')
|
||||
env.cr.execute("SELECT name from ir_module_module WHERE state IN %s", [states])
|
||||
module_list = [name for (name,) in env.cr.fetchall() if name not in graph]
|
||||
if not module_list:
|
||||
break
|
||||
graph.extend(module_list)
|
||||
_logger.debug('Updating graph with %d more modules', len(module_list))
|
||||
updated_modules_count = len(registry.updated_modules)
|
||||
load_module_graph(
|
||||
env, graph, update_module=update_module,
|
||||
report=report, models_to_check=models_to_check)
|
||||
if len(registry.updated_modules) == updated_modules_count:
|
||||
break
|
||||
|
||||
if update_module:
|
||||
# set up the registry without the patch for translated fields
|
||||
database_translated_fields = registry._database_translated_fields
|
||||
registry._database_translated_fields = ()
|
||||
registry.setup_models(cr)
|
||||
registry._database_translated_fields = {}
|
||||
registry._setup_models__(cr, []) # incremental setup
|
||||
# determine which translated fields should no longer be translated,
|
||||
# and make their model fix the database schema
|
||||
models_to_untranslate = set()
|
||||
|
|
@ -508,7 +470,7 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
|
|||
registry.init_models(cr, list(models_to_untranslate), {'models_to_check': True})
|
||||
|
||||
registry.loaded = True
|
||||
registry.setup_models(cr)
|
||||
registry._setup_models__(cr)
|
||||
|
||||
# check that all installed modules have been loaded by the registry
|
||||
Module = env['ir.module.module']
|
||||
|
|
@ -518,9 +480,10 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
|
|||
_logger.error("Some modules are not loaded, some dependencies or manifest may be missing: %s", missing)
|
||||
|
||||
# STEP 3.5: execute migration end-scripts
|
||||
migrations = odoo.modules.migration.MigrationManager(cr, graph)
|
||||
for package in graph:
|
||||
migrations.migrate_module(package, 'end')
|
||||
if update_module:
|
||||
migrations = MigrationManager(cr, graph)
|
||||
for package in graph:
|
||||
migrations.migrate_module(package, 'end')
|
||||
|
||||
# check that new module dependencies have been properly installed after a migration/upgrade
|
||||
cr.execute("SELECT name from ir_module_module WHERE state IN ('to install', 'to upgrade')")
|
||||
|
|
@ -529,10 +492,10 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
|
|||
_logger.error("Some modules have inconsistent states, some dependencies may be missing: %s", sorted(module_list))
|
||||
|
||||
# STEP 3.6: apply remaining constraints in case of an upgrade
|
||||
registry.finalize_constraints()
|
||||
registry.finalize_constraints(cr)
|
||||
|
||||
# STEP 4: Finish and cleanup installations
|
||||
if processed_modules:
|
||||
if registry.updated_modules:
|
||||
|
||||
cr.execute("SELECT model from ir_model")
|
||||
for (model,) in cr.fetchall():
|
||||
|
|
@ -542,7 +505,7 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
|
|||
_logger.runbot("Model %s is declared but cannot be loaded! (Perhaps a module was partially removed or renamed)", model)
|
||||
|
||||
# Cleanup orphan records
|
||||
env['ir.model.data']._process_end(processed_modules)
|
||||
env['ir.model.data']._process_end(registry.updated_modules)
|
||||
# Cleanup cron
|
||||
vacuum_cron = env.ref('base.autovacuum_job', raise_if_not_found=False)
|
||||
if vacuum_cron:
|
||||
|
|
@ -551,9 +514,6 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
|
|||
|
||||
env.flush_all()
|
||||
|
||||
for kind in ('init', 'demo', 'update'):
|
||||
tools.config[kind] = {}
|
||||
|
||||
# STEP 5: Uninstall modules to remove
|
||||
if update_module:
|
||||
# Remove records referenced from ir_model_data for modules to be
|
||||
|
|
@ -563,7 +523,7 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
|
|||
if modules_to_remove:
|
||||
pkgs = reversed([p for p in graph if p.name in modules_to_remove])
|
||||
for pkg in pkgs:
|
||||
uninstall_hook = pkg.info.get('uninstall_hook')
|
||||
uninstall_hook = pkg.manifest.get('uninstall_hook')
|
||||
if uninstall_hook:
|
||||
py_module = sys.modules['odoo.addons.%s' % (pkg.name,)]
|
||||
getattr(py_module, uninstall_hook)(env)
|
||||
|
|
@ -575,13 +535,13 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
|
|||
# modules to remove next time
|
||||
cr.commit()
|
||||
_logger.info('Reloading registry once more after uninstalling modules')
|
||||
registry = odoo.modules.registry.Registry.new(
|
||||
cr.dbname, force_demo, status, update_module
|
||||
registry = Registry.new(
|
||||
cr.dbname, update_module=update_module
|
||||
)
|
||||
cr.reset()
|
||||
registry.check_tables_exist(cr)
|
||||
cr.commit()
|
||||
return registry
|
||||
return
|
||||
|
||||
# STEP 5.5: Verify extended fields on every model
|
||||
# This will fix the schema of all models in a situation such as:
|
||||
|
|
@ -590,12 +550,15 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
|
|||
# - module C is loaded and extends model M;
|
||||
# - module B and C depend on A but not on each other;
|
||||
# The changes introduced by module C are not taken into account by the upgrade of B.
|
||||
if update_module:
|
||||
# We need to fix custom fields for which we have dropped the not-null constraint.
|
||||
cr.execute("""SELECT DISTINCT model FROM ir_model_fields WHERE state = 'manual'""")
|
||||
models_to_check.update(model_name for model_name, in cr.fetchall() if model_name in registry)
|
||||
if models_to_check:
|
||||
registry.init_models(cr, list(models_to_check), {'models_to_check': True})
|
||||
registry.init_models(cr, list(models_to_check), {'models_to_check': True, 'update_custom_fields': True})
|
||||
|
||||
# STEP 6: verify custom views on every model
|
||||
if update_module:
|
||||
env['res.groups']._update_user_groups_view()
|
||||
View = env['ir.ui.view']
|
||||
for model in registry:
|
||||
try:
|
||||
|
|
@ -608,21 +571,20 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
|
|||
else:
|
||||
_logger.error('At least one test failed when loading the modules.')
|
||||
|
||||
|
||||
# STEP 8: save installed/updated modules for post-install tests and _register_hook
|
||||
registry.updated_modules += processed_modules
|
||||
|
||||
# STEP 9: call _register_hook on every model
|
||||
# This is done *exactly once* when the registry is being loaded. See the
|
||||
# management of those hooks in `Registry.setup_models`: all the calls to
|
||||
# setup_models() done here do not mess up with hooks, as registry.ready
|
||||
# management of those hooks in `Registry._setup_models__`: all the calls to
|
||||
# _setup_models__() done here do not mess up with hooks, as registry.ready
|
||||
# is False.
|
||||
for model in env.values():
|
||||
model._register_hook()
|
||||
env.flush_all()
|
||||
|
||||
# STEP 10: check that we can trust nullable columns
|
||||
registry.check_null_constraints(cr)
|
||||
|
||||
def reset_modules_state(db_name):
|
||||
|
||||
def reset_modules_state(db_name: str) -> None:
|
||||
"""
|
||||
Resets modules flagged as "to x" to their original state
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,21 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
""" Modules migration handling. """
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import importlib.util
|
||||
import inspect
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import typing
|
||||
from collections import defaultdict
|
||||
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.modules.module import load_script
|
||||
from odoo.orm.registry import Registry
|
||||
from odoo.tools.misc import file_path
|
||||
from odoo.tools.parse_version import parse_version
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from odoo.sql_db import Cursor
|
||||
from . import module_graph
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -47,15 +55,7 @@ VERSION_RE = re.compile(
|
|||
)
|
||||
|
||||
|
||||
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):
|
||||
class MigrationManager:
|
||||
""" Manages the migration of modules.
|
||||
|
||||
Migrations files must be python files containing a ``migrate(cr, installed_version)``
|
||||
|
|
@ -86,21 +86,22 @@ class MigrationManager(object):
|
|||
| `-- end-invariants.py # processed on all version update
|
||||
`-- foo.py # not processed
|
||||
"""
|
||||
migrations: defaultdict[str, dict]
|
||||
|
||||
def __init__(self, cr, graph):
|
||||
def __init__(self, cr: Cursor, graph: module_graph.ModuleGraph):
|
||||
self.cr = cr
|
||||
self.graph = graph
|
||||
self.migrations = defaultdict(dict)
|
||||
self._get_files()
|
||||
|
||||
def _get_files(self):
|
||||
def _get_upgrade_path(pkg):
|
||||
def _get_files(self) -> None:
|
||||
def _get_upgrade_path(pkg: str) -> Iterator[str]:
|
||||
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):
|
||||
def _verify_upgrade_version(path: str, version: str) -> bool:
|
||||
full_path = opj(path, version)
|
||||
if not os.path.isdir(full_path):
|
||||
return False
|
||||
|
|
@ -114,7 +115,7 @@ class MigrationManager(object):
|
|||
|
||||
return True
|
||||
|
||||
def get_scripts(path):
|
||||
def get_scripts(path: str) -> dict[str, list[str]]:
|
||||
if not path:
|
||||
return {}
|
||||
return {
|
||||
|
|
@ -123,15 +124,14 @@ class MigrationManager(object):
|
|||
if _verify_upgrade_version(path, version)
|
||||
}
|
||||
|
||||
def check_path(path):
|
||||
def check_path(path: str) -> str:
|
||||
try:
|
||||
return file_path(path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
return ''
|
||||
|
||||
for pkg in self.graph:
|
||||
if not (hasattr(pkg, 'update') or pkg.state == 'to upgrade' or
|
||||
getattr(pkg, 'load_state', None) == 'to upgrade'):
|
||||
if pkg.load_state != 'to upgrade' and pkg.name not in Registry(self.cr.dbname)._force_upgrade_scripts:
|
||||
continue
|
||||
|
||||
|
||||
|
|
@ -146,26 +146,24 @@ class MigrationManager(object):
|
|||
scripts[v].extend(s)
|
||||
self.migrations[pkg.name]["upgrade"] = scripts
|
||||
|
||||
def migrate_module(self, pkg, stage):
|
||||
def migrate_module(self, pkg: module_graph.ModuleNode, stage: typing.Literal['pre', 'post', 'end']) -> None:
|
||||
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':
|
||||
if pkg.load_state != 'to upgrade' and pkg.name not in Registry(self.cr.dbname)._force_upgrade_scripts:
|
||||
return
|
||||
|
||||
def convert_version(version):
|
||||
def convert_version(version: str) -> str:
|
||||
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):
|
||||
def _get_migration_versions(pkg, stage: str) -> list[str]:
|
||||
versions = sorted({
|
||||
ver
|
||||
for lv in self.migrations[pkg.name].values()
|
||||
|
|
@ -196,11 +194,11 @@ class MigrationManager(object):
|
|||
key=os.path.basename,
|
||||
)
|
||||
|
||||
installed_version = getattr(pkg, 'load_version', pkg.installed_version) or ''
|
||||
installed_version = pkg.load_version or ''
|
||||
parsed_installed_version = parse_version(installed_version)
|
||||
current_version = parse_version(convert_version(pkg.data['version']))
|
||||
current_version = parse_version(convert_version(pkg.manifest['version']))
|
||||
|
||||
def compare(version):
|
||||
def compare(version: str) -> bool:
|
||||
if version == "0.0.0" and parsed_installed_version < current_version:
|
||||
return True
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
"""Utility functions to manage module manifest files and discovery."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import collections.abc
|
||||
import copy
|
||||
import functools
|
||||
import importlib
|
||||
|
|
@ -12,21 +12,23 @@ import os
|
|||
import re
|
||||
import sys
|
||||
import traceback
|
||||
import typing
|
||||
import warnings
|
||||
from os.path import join as opj, normpath
|
||||
from collections.abc import Collection, Iterable, Mapping
|
||||
from os.path import join as opj
|
||||
|
||||
import odoo
|
||||
import odoo.tools as tools
|
||||
import odoo.addons
|
||||
import odoo.release as release
|
||||
from odoo.tools.misc import file_path
|
||||
import odoo.tools as tools
|
||||
import odoo.upgrade
|
||||
|
||||
try:
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
except ImportError:
|
||||
class InvalidRequirement(Exception):
|
||||
class InvalidRequirement(Exception): # type: ignore[no-redef]
|
||||
...
|
||||
|
||||
class Requirement:
|
||||
class Requirement: # type: ignore[no-redef]
|
||||
def __init__(self, pydep):
|
||||
if not re.fullmatch(r'[\w\-]+', pydep): # check that we have no versions or marker in pydep
|
||||
msg = f"Package `packaging` is required to parse `{pydep}` external dependency and is not installed"
|
||||
|
|
@ -35,42 +37,56 @@ except ImportError:
|
|||
self.specifier = None
|
||||
self.name = pydep
|
||||
|
||||
__all__ = [
|
||||
"Manifest",
|
||||
"adapt_version",
|
||||
"get_manifest",
|
||||
"get_module_path",
|
||||
"get_modules",
|
||||
"get_modules_with_version",
|
||||
"get_resource_from_path",
|
||||
"initialize_sys_path",
|
||||
"load_openerp_module",
|
||||
]
|
||||
|
||||
MANIFEST_NAMES = ('__manifest__.py', '__openerp__.py')
|
||||
README = ['README.rst', 'README.md', 'README.txt']
|
||||
MODULE_NAME_RE = re.compile(r'^\w{1,256}$')
|
||||
MANIFEST_NAMES = ['__manifest__.py']
|
||||
README = ['README.rst', 'README.md', 'README.txt', 'README']
|
||||
|
||||
_DEFAULT_MANIFEST = {
|
||||
#addons_path: f'/path/to/the/addons/path/of/{module}', # automatic
|
||||
# Mandatory fields (with no defaults):
|
||||
# - author
|
||||
# - license
|
||||
# - name
|
||||
# Derived fields are computed in the Manifest class.
|
||||
'application': False,
|
||||
'bootstrap': False, # web
|
||||
'assets': {},
|
||||
'author': 'Odoo S.A.',
|
||||
'auto_install': False,
|
||||
'category': 'Uncategorized',
|
||||
'cloc_exclude': [],
|
||||
'configurator_snippets': {}, # website themes
|
||||
'configurator_snippets_addons': {}, # website themes
|
||||
'countries': [],
|
||||
'data': [],
|
||||
'demo': [],
|
||||
'demo_xml': [],
|
||||
'depends': [],
|
||||
'description': '',
|
||||
'description': '', # defaults to README file
|
||||
'external_dependencies': {},
|
||||
#icon: f'/{module}/static/description/icon.png', # automatic
|
||||
'init_xml': [],
|
||||
'installable': True,
|
||||
'images': [], # website
|
||||
'images_preview_theme': {}, # website themes
|
||||
#license, mandatory
|
||||
'live_test_url': '', # website themes
|
||||
'new_page_templates': {}, # website themes
|
||||
#name, mandatory
|
||||
'post_init_hook': '',
|
||||
'post_load': '',
|
||||
'pre_init_hook': '',
|
||||
'sequence': 100,
|
||||
'summary': '',
|
||||
'test': [],
|
||||
'theme_customizations': {}, # themes
|
||||
'update_xml': [],
|
||||
'uninstall_hook': '',
|
||||
'version': '1.0',
|
||||
|
|
@ -91,8 +107,11 @@ TYPED_FIELD_DEFINITION_RE = re.compile(r'''
|
|||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
current_test: bool = False
|
||||
"""Indicates whteher we are in a test mode"""
|
||||
|
||||
class UpgradeHook(object):
|
||||
|
||||
class UpgradeHook:
|
||||
"""Makes the legacy `migrations` package being `odoo.upgrade`"""
|
||||
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
|
|
@ -118,49 +137,197 @@ class UpgradeHook(object):
|
|||
return sys.modules[name]
|
||||
|
||||
|
||||
def initialize_sys_path():
|
||||
def initialize_sys_path() -> None:
|
||||
"""
|
||||
Setup the addons path ``odoo.addons.__path__`` with various defaults
|
||||
and explicit directories.
|
||||
"""
|
||||
# hook odoo.addons on data dir
|
||||
dd = os.path.normcase(tools.config.addons_data_dir)
|
||||
if os.access(dd, os.R_OK) and dd not in odoo.addons.__path__:
|
||||
odoo.addons.__path__.append(dd)
|
||||
|
||||
# hook odoo.addons on addons paths
|
||||
for ad in tools.config['addons_path'].split(','):
|
||||
ad = os.path.normcase(os.path.abspath(ad.strip()))
|
||||
if ad not in odoo.addons.__path__:
|
||||
odoo.addons.__path__.append(ad)
|
||||
|
||||
# hook odoo.addons on base module path
|
||||
base_path = os.path.normcase(os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'addons')))
|
||||
if base_path not in odoo.addons.__path__ and os.path.isdir(base_path):
|
||||
odoo.addons.__path__.append(base_path)
|
||||
for path in (
|
||||
# tools.config.addons_base_dir, # already present
|
||||
tools.config.addons_data_dir,
|
||||
*tools.config['addons_path'],
|
||||
tools.config.addons_community_dir,
|
||||
):
|
||||
if os.access(path, os.R_OK) and path not in odoo.addons.__path__:
|
||||
odoo.addons.__path__.append(path)
|
||||
|
||||
# hook odoo.upgrade on upgrade-path
|
||||
from odoo import upgrade
|
||||
legacy_upgrade_path = os.path.join(base_path, 'base', 'maintenance', 'migrations')
|
||||
for up in (tools.config['upgrade_path'] or legacy_upgrade_path).split(','):
|
||||
up = os.path.normcase(os.path.abspath(up.strip()))
|
||||
if os.path.isdir(up) and up not in upgrade.__path__:
|
||||
upgrade.__path__.append(up)
|
||||
legacy_upgrade_path = os.path.join(tools.config.addons_base_dir, 'base/maintenance/migrations')
|
||||
for up in tools.config['upgrade_path'] or [legacy_upgrade_path]:
|
||||
if up not in odoo.upgrade.__path__:
|
||||
odoo.upgrade.__path__.append(up)
|
||||
|
||||
# create decrecated module alias from odoo.addons.base.maintenance.migrations to odoo.upgrade
|
||||
spec = importlib.machinery.ModuleSpec("odoo.addons.base.maintenance", None, is_package=True)
|
||||
maintenance_pkg = importlib.util.module_from_spec(spec)
|
||||
maintenance_pkg.migrations = upgrade
|
||||
maintenance_pkg.migrations = odoo.upgrade # type: ignore
|
||||
sys.modules["odoo.addons.base.maintenance"] = maintenance_pkg
|
||||
sys.modules["odoo.addons.base.maintenance.migrations"] = upgrade
|
||||
sys.modules["odoo.addons.base.maintenance.migrations"] = odoo.upgrade
|
||||
|
||||
# hook deprecated module alias from openerp to odoo and "crm"-like to odoo.addons
|
||||
if not getattr(initialize_sys_path, 'called', False): # only initialize once
|
||||
# hook for upgrades and namespace freeze
|
||||
if not getattr(initialize_sys_path, 'called', False): # only initialize once
|
||||
odoo.addons.__path__._path_finder = lambda *a: None # prevent path invalidation
|
||||
odoo.upgrade.__path__._path_finder = lambda *a: None # prevent path invalidation
|
||||
sys.meta_path.insert(0, UpgradeHook())
|
||||
initialize_sys_path.called = True
|
||||
initialize_sys_path.called = True # type: ignore
|
||||
|
||||
|
||||
def get_module_path(module, downloaded=False, display_warning=True):
|
||||
@typing.final
|
||||
class Manifest(Mapping[str, typing.Any]):
|
||||
"""The manifest data of a module."""
|
||||
|
||||
def __init__(self, *, path: str, manifest_content: dict):
|
||||
assert os.path.isabs(path), "path of module must be absolute"
|
||||
self.path = path
|
||||
_, self.name = os.path.split(path)
|
||||
if not MODULE_NAME_RE.match(self.name):
|
||||
raise FileNotFoundError(f"Invalid module name: {self.name}")
|
||||
self._manifest_content = manifest_content
|
||||
|
||||
@property
|
||||
def addons_path(self) -> str:
|
||||
parent_path, name = os.path.split(self.path)
|
||||
assert name == self.name
|
||||
return parent_path
|
||||
|
||||
@functools.cached_property
|
||||
def manifest_cached(self) -> dict:
|
||||
"""Parsed and validated manifest data from the file."""
|
||||
return _load_manifest(self.name, self._manifest_content)
|
||||
|
||||
@functools.cached_property
|
||||
def description(self):
|
||||
"""The description of the module defaulting to the README file."""
|
||||
if (desc := self.manifest_cached.get('description')):
|
||||
return desc
|
||||
for file_name in README:
|
||||
try:
|
||||
with tools.file_open(opj(self.path, file_name)) as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
pass
|
||||
return ''
|
||||
|
||||
@functools.cached_property
|
||||
def version(self):
|
||||
try:
|
||||
return self.manifest_cached['version']
|
||||
except Exception: # noqa: BLE001
|
||||
return adapt_version('1.0')
|
||||
|
||||
@functools.cached_property
|
||||
def icon(self) -> str:
|
||||
return get_module_icon(self.name)
|
||||
|
||||
@functools.cached_property
|
||||
def static_path(self) -> str | None:
|
||||
static_path = opj(self.path, 'static')
|
||||
manifest = self.manifest_cached
|
||||
if (manifest['installable'] or manifest['assets']) and os.path.isdir(static_path):
|
||||
return static_path
|
||||
return None
|
||||
|
||||
def __getitem__(self, key: str):
|
||||
if key in ('description', 'icon', 'addons_path', 'version', 'static_path'):
|
||||
return getattr(self, key)
|
||||
return copy.deepcopy(self.manifest_cached[key])
|
||||
|
||||
def __iter__(self):
|
||||
manifest = self.manifest_cached
|
||||
yield from manifest
|
||||
for key in ('description', 'icon', 'addons_path', 'version', 'static_path'):
|
||||
if key not in manifest:
|
||||
yield key
|
||||
|
||||
def check_manifest_dependencies(self) -> None:
|
||||
"""Check that the dependecies of the manifest are available.
|
||||
|
||||
- Checking for external python dependencies
|
||||
- Checking binaries are available in PATH
|
||||
|
||||
On missing dependencies, raise an error.
|
||||
"""
|
||||
depends = self.get('external_dependencies')
|
||||
if not depends:
|
||||
return
|
||||
for pydep in depends.get('python', []):
|
||||
check_python_external_dependency(pydep)
|
||||
|
||||
for binary in depends.get('bin', []):
|
||||
try:
|
||||
tools.find_in_path(binary)
|
||||
except OSError:
|
||||
msg = "Unable to find {dependency!r} in path"
|
||||
raise MissingDependency(msg, binary)
|
||||
|
||||
def __bool__(self):
|
||||
return True
|
||||
|
||||
def __len__(self):
|
||||
return sum(1 for _ in self)
|
||||
|
||||
def __repr__(self):
|
||||
return f'Manifest({self.name})'
|
||||
|
||||
# limit cache size because this may get called from any module with any input
|
||||
@staticmethod
|
||||
@functools.lru_cache(10_000)
|
||||
def _get_manifest_from_addons(module: str) -> Manifest | None:
|
||||
"""Get the module's manifest from a name. Searching only in addons paths."""
|
||||
for adp in odoo.addons.__path__:
|
||||
if manifest := Manifest._from_path(opj(adp, module)):
|
||||
return manifest
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def for_addon(module_name: str, *, display_warning: bool = True) -> Manifest | None:
|
||||
"""Get the module's manifest from a name.
|
||||
|
||||
:param module: module's name
|
||||
:param display_warning: log a warning if the module is not found
|
||||
"""
|
||||
if not MODULE_NAME_RE.match(module_name):
|
||||
# invalid module name
|
||||
return None
|
||||
if mod := Manifest._get_manifest_from_addons(module_name):
|
||||
return mod
|
||||
if display_warning:
|
||||
_logger.warning('module %s: manifest not found', module_name)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _from_path(path: str, env=None) -> Manifest | None:
|
||||
"""Given a path, read the manifest file."""
|
||||
for manifest_name in MANIFEST_NAMES:
|
||||
try:
|
||||
with tools.file_open(opj(path, manifest_name), env=env) as f:
|
||||
manifest_content = ast.literal_eval(f.read())
|
||||
except OSError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.debug("Failed to parse the manifest file at %r", path, exc_info=True)
|
||||
else:
|
||||
return Manifest(path=path, manifest_content=manifest_content)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def all_addon_manifests() -> list[Manifest]:
|
||||
"""Read all manifests in the addons paths."""
|
||||
modules: dict[str, Manifest] = {}
|
||||
for adp in odoo.addons.__path__:
|
||||
if not os.path.isdir(adp):
|
||||
_logger.warning("addons path is not a directory: %s", adp)
|
||||
continue
|
||||
for file_name in os.listdir(adp):
|
||||
if file_name in modules:
|
||||
continue
|
||||
if mod := Manifest._from_path(opj(adp, file_name)):
|
||||
assert file_name == mod.name
|
||||
modules[file_name] = mod
|
||||
return sorted(modules.values(), key=lambda m: m.name)
|
||||
|
||||
|
||||
def get_module_path(module: str, display_warning: bool = True) -> str | None:
|
||||
"""Return the path of the given module.
|
||||
|
||||
Search the addons paths and return the first path where the given
|
||||
|
|
@ -168,44 +335,12 @@ def get_module_path(module, downloaded=False, display_warning=True):
|
|||
path if nothing else is found.
|
||||
|
||||
"""
|
||||
if re.search(r"[\/\\]", module):
|
||||
return False
|
||||
for adp in odoo.addons.__path__:
|
||||
files = [opj(adp, module, manifest) for manifest in MANIFEST_NAMES] +\
|
||||
[opj(adp, module + '.zip')]
|
||||
if any(os.path.exists(f) for f in files):
|
||||
return opj(adp, module)
|
||||
# TODO deprecate
|
||||
mod = Manifest.for_addon(module, display_warning=display_warning)
|
||||
return mod.path if mod else None
|
||||
|
||||
if downloaded:
|
||||
return opj(tools.config.addons_data_dir, module)
|
||||
if display_warning:
|
||||
_logger.warning('module %s: module not found', module)
|
||||
return False
|
||||
|
||||
def get_resource_path(module, *args):
|
||||
"""Return the full path of a resource of the given module.
|
||||
|
||||
:param module: module name
|
||||
:param list(str) args: resource path components within module
|
||||
|
||||
:rtype: str
|
||||
:return: absolute path to the resource
|
||||
"""
|
||||
warnings.warn(
|
||||
f"Since 17.0: use tools.misc.file_path instead of get_resource_path({module}, {args})",
|
||||
DeprecationWarning,
|
||||
)
|
||||
resource_path = opj(module, *args)
|
||||
try:
|
||||
return file_path(resource_path)
|
||||
except (FileNotFoundError, ValueError):
|
||||
return False
|
||||
|
||||
# backwards compatibility
|
||||
get_module_resource = get_resource_path
|
||||
check_resource_path = get_resource_path
|
||||
|
||||
def get_resource_from_path(path):
|
||||
def get_resource_from_path(path: str) -> tuple[str, str, str] | None:
|
||||
"""Tries to extract the module name and the resource's relative path
|
||||
out of an absolute resource path.
|
||||
|
||||
|
|
@ -220,7 +355,7 @@ def get_resource_from_path(path):
|
|||
:rtype: tuple
|
||||
:return: tuple(module_name, relative_path, os_relative_path) if possible, else None
|
||||
"""
|
||||
resource = False
|
||||
resource = None
|
||||
sorted_paths = sorted(odoo.addons.__path__, key=len, reverse=True)
|
||||
for adpath in sorted_paths:
|
||||
# force trailing separator
|
||||
|
|
@ -237,118 +372,94 @@ def get_resource_from_path(path):
|
|||
return (module, '/'.join(relative), os.path.sep.join(relative))
|
||||
return None
|
||||
|
||||
def get_module_icon(module):
|
||||
fpath = f"{module}/static/description/icon.png"
|
||||
|
||||
def get_module_icon(module: str) -> str:
|
||||
""" Get the path to the module's icon. Invalid module names are accepted. """
|
||||
manifest = Manifest.for_addon(module, display_warning=False)
|
||||
if manifest and 'icon' in manifest.__dict__:
|
||||
return manifest.icon
|
||||
try:
|
||||
file_path(fpath)
|
||||
fpath = f"{module}/static/description/icon.png"
|
||||
tools.file_path(fpath)
|
||||
return "/" + fpath
|
||||
except FileNotFoundError:
|
||||
return "/base/static/description/icon.png"
|
||||
|
||||
def get_module_icon_path(module):
|
||||
try:
|
||||
return file_path(f"{module}/static/description/icon.png")
|
||||
except FileNotFoundError:
|
||||
return file_path("base/static/description/icon.png")
|
||||
|
||||
def module_manifest(path):
|
||||
"""Returns path to module manifest if one can be found under `path`, else `None`."""
|
||||
if not path:
|
||||
return None
|
||||
for manifest_name in MANIFEST_NAMES:
|
||||
candidate = opj(path, manifest_name)
|
||||
if os.path.isfile(candidate):
|
||||
if manifest_name == '__openerp__.py':
|
||||
warnings.warn(
|
||||
"__openerp__.py manifests are deprecated since 17.0, "
|
||||
f"rename {candidate!r} to __manifest__.py "
|
||||
"(valid since 10.0)",
|
||||
category=DeprecationWarning
|
||||
)
|
||||
return candidate
|
||||
|
||||
def get_module_root(path):
|
||||
"""
|
||||
Get closest module's root beginning from path
|
||||
|
||||
# Given:
|
||||
# /foo/bar/module_dir/static/src/...
|
||||
|
||||
get_module_root('/foo/bar/module_dir/static/')
|
||||
# returns '/foo/bar/module_dir'
|
||||
|
||||
get_module_root('/foo/bar/module_dir/')
|
||||
# returns '/foo/bar/module_dir'
|
||||
|
||||
get_module_root('/foo/bar')
|
||||
# returns None
|
||||
|
||||
@param path: Path from which the lookup should start
|
||||
|
||||
@return: Module root path or None if not found
|
||||
"""
|
||||
while not module_manifest(path):
|
||||
new_path = os.path.abspath(opj(path, os.pardir))
|
||||
if path == new_path:
|
||||
return None
|
||||
path = new_path
|
||||
return path
|
||||
|
||||
def load_manifest(module, mod_path=None):
|
||||
def load_manifest(module: str, mod_path: str | None = None) -> dict:
|
||||
""" Load the module manifest from the file system. """
|
||||
warnings.warn("Since 19.0, use Manifest", DeprecationWarning)
|
||||
|
||||
if not mod_path:
|
||||
mod_path = get_module_path(module, downloaded=True)
|
||||
manifest_file = module_manifest(mod_path)
|
||||
|
||||
if not manifest_file:
|
||||
if mod_path:
|
||||
mod = Manifest._from_path(mod_path)
|
||||
assert mod.path == mod_path
|
||||
else:
|
||||
mod = Manifest.for_addon(module)
|
||||
if not mod:
|
||||
_logger.debug('module %s: no manifest file found %s', module, MANIFEST_NAMES)
|
||||
return {}
|
||||
|
||||
return dict(mod)
|
||||
|
||||
|
||||
def _load_manifest(module: str, manifest_content: dict) -> dict:
|
||||
""" Load and validate the module manifest.
|
||||
|
||||
Return a new dictionary with cleaned and validated keys.
|
||||
"""
|
||||
|
||||
manifest = copy.deepcopy(_DEFAULT_MANIFEST)
|
||||
manifest.update(manifest_content)
|
||||
|
||||
manifest['icon'] = get_module_icon(module)
|
||||
|
||||
with tools.file_open(manifest_file, mode='r') as f:
|
||||
manifest.update(ast.literal_eval(f.read()))
|
||||
|
||||
if not manifest['description']:
|
||||
readme_path = [opj(mod_path, x) for x in README
|
||||
if os.path.isfile(opj(mod_path, x))]
|
||||
if readme_path:
|
||||
with tools.file_open(readme_path[0]) as fd:
|
||||
manifest['description'] = fd.read()
|
||||
if not manifest.get('author'):
|
||||
# Altought contributors and maintainer are not documented, it is
|
||||
# not uncommon to find them in manifest files, use them as
|
||||
# alternative.
|
||||
author = manifest.get('contributors') or manifest.get('maintainer') or ''
|
||||
manifest['author'] = str(author)
|
||||
_logger.warning("Missing `author` key in manifest for %r, defaulting to %r", module, str(author))
|
||||
|
||||
if not manifest.get('license'):
|
||||
manifest['license'] = 'LGPL-3'
|
||||
_logger.warning("Missing `license` key in manifest for %r, defaulting to LGPL-3", module)
|
||||
|
||||
if module == 'base':
|
||||
manifest['depends'] = []
|
||||
elif not manifest['depends']:
|
||||
# prevent the hack `'depends': []` except 'base' module
|
||||
manifest['depends'] = ['base']
|
||||
|
||||
depends = manifest['depends']
|
||||
assert isinstance(depends, Collection)
|
||||
|
||||
# auto_install is either `False` (by default) in which case the module
|
||||
# is opt-in, either a list of dependencies in which case the module is
|
||||
# automatically installed if all dependencies are (special case: [] to
|
||||
# always install the module), either `True` to auto-install the module
|
||||
# in case all dependencies declared in `depends` are installed.
|
||||
if isinstance(manifest['auto_install'], collections.abc.Iterable):
|
||||
manifest['auto_install'] = set(manifest['auto_install'])
|
||||
non_dependencies = manifest['auto_install'].difference(manifest['depends'])
|
||||
assert not non_dependencies,\
|
||||
"auto_install triggers must be dependencies, found " \
|
||||
"non-dependencies [%s] for module %s" % (
|
||||
', '.join(non_dependencies), module
|
||||
)
|
||||
if isinstance(manifest['auto_install'], Iterable):
|
||||
manifest['auto_install'] = auto_install_set = set(manifest['auto_install'])
|
||||
non_dependencies = auto_install_set.difference(depends)
|
||||
assert not non_dependencies, (
|
||||
"auto_install triggers must be dependencies,"
|
||||
f" found non-dependencies [{', '.join(non_dependencies)}] for module {module}"
|
||||
)
|
||||
elif manifest['auto_install']:
|
||||
manifest['auto_install'] = set(manifest['depends'])
|
||||
manifest['auto_install'] = set(depends)
|
||||
|
||||
try:
|
||||
manifest['version'] = adapt_version(manifest['version'])
|
||||
manifest['version'] = adapt_version(str(manifest['version']))
|
||||
except ValueError as e:
|
||||
if manifest.get("installable", True):
|
||||
if manifest['installable']:
|
||||
raise ValueError(f"Module {module}: invalid manifest") from e
|
||||
manifest['addons_path'] = normpath(opj(mod_path, os.pardir))
|
||||
if manifest['installable'] and not check_version(str(manifest['version']), should_raise=False):
|
||||
_logger.warning("The module %s has an incompatible version, setting installable=False", module)
|
||||
manifest['installable'] = False
|
||||
|
||||
return manifest
|
||||
|
||||
def get_manifest(module, mod_path=None):
|
||||
|
||||
def get_manifest(module: str, mod_path: str | None = None) -> Mapping[str, typing.Any]:
|
||||
"""
|
||||
Get the module manifest.
|
||||
|
||||
|
|
@ -358,16 +469,17 @@ def get_manifest(module, mod_path=None):
|
|||
addons-paths.
|
||||
:returns: The module manifest as a dict or an empty dict
|
||||
when the manifest was not found.
|
||||
:rtype: dict
|
||||
"""
|
||||
return copy.deepcopy(_get_manifest_cached(module, mod_path))
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _get_manifest_cached(module, mod_path=None):
|
||||
return load_manifest(module, mod_path)
|
||||
if mod_path:
|
||||
mod = Manifest._from_path(mod_path)
|
||||
if mod and mod.name != module:
|
||||
raise ValueError(f"Invalid path for module {module}: {mod_path}")
|
||||
else:
|
||||
mod = Manifest.for_addon(module, display_warning=False)
|
||||
return mod if mod is not None else {}
|
||||
|
||||
|
||||
def load_openerp_module(module_name):
|
||||
def load_openerp_module(module_name: str) -> None:
|
||||
""" Load an OpenERP module, if not already loaded.
|
||||
|
||||
This loads the module and register all of its models, thanks to either
|
||||
|
|
@ -386,9 +498,9 @@ def load_openerp_module(module_name):
|
|||
# Call the module's post-load hook. This can done before any model or
|
||||
# data has been initialized. This is ok as the post-load hook is for
|
||||
# server-wide (instead of registry-specific) functionalities.
|
||||
info = get_manifest(module_name)
|
||||
if info['post_load']:
|
||||
getattr(sys.modules[qualname], info['post_load'])()
|
||||
manifest = Manifest.for_addon(module_name)
|
||||
if post_load := manifest.get('post_load'):
|
||||
getattr(sys.modules[qualname], post_load)()
|
||||
|
||||
except AttributeError as err:
|
||||
_logger.critical("Couldn't load module %s", module_name)
|
||||
|
|
@ -412,69 +524,63 @@ def load_openerp_module(module_name):
|
|||
_logger.critical("Couldn't load module %s", module_name)
|
||||
raise
|
||||
|
||||
def get_modules():
|
||||
"""Returns the list of module names
|
||||
|
||||
def get_modules() -> list[str]:
|
||||
"""Get the list of module names that can be loaded.
|
||||
"""
|
||||
def listdir(dir):
|
||||
def clean(name):
|
||||
name = os.path.basename(name)
|
||||
if name[-4:] == '.zip':
|
||||
name = name[:-4]
|
||||
return name
|
||||
return [m.name for m in Manifest.all_addon_manifests()]
|
||||
|
||||
def is_really_module(name):
|
||||
for mname in MANIFEST_NAMES:
|
||||
if os.path.isfile(opj(dir, name, mname)):
|
||||
return True
|
||||
return [
|
||||
clean(it)
|
||||
for it in os.listdir(dir)
|
||||
if is_really_module(it)
|
||||
]
|
||||
|
||||
plist = []
|
||||
for ad in odoo.addons.__path__:
|
||||
if not os.path.exists(ad):
|
||||
_logger.warning("addons path does not exist: %s", ad)
|
||||
continue
|
||||
plist.extend(listdir(ad))
|
||||
return sorted(set(plist))
|
||||
def get_modules_with_version() -> dict[str, str]:
|
||||
"""Get the module list with the linked version."""
|
||||
warnings.warn("Since 19.0, use Manifest.all_addon_manifests", DeprecationWarning)
|
||||
return {m.name: m.version for m in Manifest.all_addon_manifests()}
|
||||
|
||||
def get_modules_with_version():
|
||||
modules = get_modules()
|
||||
res = dict.fromkeys(modules, adapt_version('1.0'))
|
||||
for module in modules:
|
||||
try:
|
||||
info = get_manifest(module)
|
||||
res[module] = info['version']
|
||||
except Exception:
|
||||
continue
|
||||
return res
|
||||
|
||||
def adapt_version(version):
|
||||
def adapt_version(version: str) -> str:
|
||||
"""Reformat the version of the module into a canonical format."""
|
||||
version_str_parts = version.split('.')
|
||||
if not (2 <= len(version_str_parts) <= 5):
|
||||
raise ValueError(f"Invalid version {version!r}, must have between 2 and 5 parts")
|
||||
serie = release.major_version
|
||||
if version == serie or not version.startswith(serie + '.'):
|
||||
base_version = version
|
||||
version = '%s.%s' % (serie, version)
|
||||
else:
|
||||
base_version = version[len(serie) + 1:]
|
||||
|
||||
if not re.match(r"^[0-9]+\.[0-9]+(?:\.[0-9]+)?$", base_version):
|
||||
raise ValueError(f"Invalid version {base_version!r}. Modules should have a version in format `x.y`, `x.y.z`,"
|
||||
f" `{serie}.x.y` or `{serie}.x.y.z`.")
|
||||
|
||||
if version.startswith(serie) and not version_str_parts[0].isdigit():
|
||||
# keep only digits for parsing
|
||||
version_str_parts[0] = ''.join(c for c in version_str_parts[0] if c.isdigit())
|
||||
try:
|
||||
version_parts = [int(v) for v in version_str_parts]
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid version {version!r}") from e
|
||||
if len(version_parts) <= 3 and not version.startswith(serie):
|
||||
# prefix the version with serie
|
||||
return f"{serie}.{version}"
|
||||
return version
|
||||
|
||||
|
||||
current_test = False
|
||||
def check_version(version: str, should_raise: bool = True) -> bool:
|
||||
"""Check that the version is in a valid format for the current release."""
|
||||
version = adapt_version(version)
|
||||
serie = release.major_version
|
||||
if version.startswith(serie + '.'):
|
||||
return True
|
||||
if should_raise:
|
||||
raise ValueError(
|
||||
f"Invalid version {version!r}. Modules should have a version in format"
|
||||
f" `x.y`, `x.y.z`, `{serie}.x.y` or `{serie}.x.y.z`.")
|
||||
return False
|
||||
|
||||
|
||||
def check_python_external_dependency(pydep):
|
||||
class MissingDependency(Exception):
|
||||
def __init__(self, msg_template: str, dependency: str):
|
||||
self.dependency = dependency
|
||||
super().__init__(msg_template.format(dependency=dependency))
|
||||
|
||||
|
||||
def check_python_external_dependency(pydep: str) -> None:
|
||||
try:
|
||||
requirement = Requirement(pydep)
|
||||
except InvalidRequirement as e:
|
||||
msg = f"{pydep} is an invalid external dependency specification: {e}"
|
||||
raise Exception(msg) from e
|
||||
raise ValueError(msg) from e
|
||||
if requirement.marker and not requirement.marker.evaluate():
|
||||
_logger.debug(
|
||||
"Ignored external dependency %s because environment markers do not match",
|
||||
|
|
@ -491,22 +597,17 @@ def check_python_external_dependency(pydep):
|
|||
return
|
||||
except ImportError:
|
||||
pass
|
||||
msg = f"External dependency {pydep} not installed: {e}"
|
||||
raise Exception(msg) from e
|
||||
msg = "External dependency {dependency!r} not installed: %s" % (e,)
|
||||
raise MissingDependency(msg, pydep) from e
|
||||
if requirement.specifier and not requirement.specifier.contains(version):
|
||||
msg = f"External dependency version mismatch: {pydep} (installed: {version})"
|
||||
raise Exception(msg)
|
||||
msg = f"External dependency version mismatch: {{dependency}} (installed: {version})"
|
||||
raise MissingDependency(msg, pydep)
|
||||
|
||||
|
||||
def check_manifest_dependencies(manifest):
|
||||
depends = manifest.get('external_dependencies')
|
||||
if not depends:
|
||||
return
|
||||
for pydep in depends.get('python', []):
|
||||
check_python_external_dependency(pydep)
|
||||
|
||||
for binary in depends.get('bin', []):
|
||||
try:
|
||||
tools.find_in_path(binary)
|
||||
except IOError:
|
||||
raise Exception('Unable to find %r in path' % (binary,))
|
||||
def load_script(path: str, module_name: str):
|
||||
full_path = tools.file_path(path) if not os.path.isabs(path) else path
|
||||
spec = importlib.util.spec_from_file_location(module_name, full_path)
|
||||
assert spec and spec.loader, f"spec not found for {module_name}"
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
|
|
|||
313
odoo-bringout-oca-ocb-base/odoo/modules/module_graph.py
Normal file
313
odoo-bringout-oca-ocb-base/odoo/modules/module_graph.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
""" Modules dependency graph. """
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from odoo.tools import reset_cached_properties, OrderedSet
|
||||
from odoo.tools.sql import column_exists
|
||||
|
||||
from .module import Manifest
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from collections.abc import Collection, Iterable, Iterator, Mapping
|
||||
from typing import Literal
|
||||
from odoo.sql_db import BaseCursor
|
||||
|
||||
STATES = Literal[
|
||||
'uninstallable',
|
||||
'uninstalled',
|
||||
'installed',
|
||||
'to upgrade',
|
||||
'to remove',
|
||||
'to install',
|
||||
]
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# THE LOADING ORDER
|
||||
#
|
||||
# Dependency Graph:
|
||||
# +---------+
|
||||
# | base |
|
||||
# +---------+
|
||||
# ^
|
||||
# |
|
||||
# |
|
||||
# +---------+
|
||||
# | module1 | <-----+
|
||||
# +---------+ |
|
||||
# ^ |
|
||||
# | |
|
||||
# | |
|
||||
# +---------+ +---------+
|
||||
# +> | module2 | | module3 |
|
||||
# | +---------+ +---------+
|
||||
# | ^ ^ ^
|
||||
# | | | |
|
||||
# | | | |
|
||||
# | +---------+ | | +---------+
|
||||
# | | module4 | ------+ +- | module5 |
|
||||
# | +---------+ +---------+
|
||||
# | ^
|
||||
# | |
|
||||
# | |
|
||||
# | +---------+
|
||||
# +- | module6 |
|
||||
# +---------+
|
||||
#
|
||||
#
|
||||
# We always load module base in the zeroth phase, because
|
||||
# 1. base should always be the single drain of the dependency graph
|
||||
# 2. we need to use models in the base to upgrade other modules
|
||||
#
|
||||
# If the ModuleGraph is in the 'load' mode
|
||||
# all non-base modules are loaded in the same phase
|
||||
# the loading order of modules in the same phase are sorted by the (depth, order_name)
|
||||
# where depth is the longest distance from the module to the base module along the dependency graph.
|
||||
# For example: the depth of module6 is 4 (path: module6 -> module4 -> module2 -> module1 -> base)
|
||||
# As a result, the loading order is
|
||||
# phase 0: base
|
||||
# phase 1: module1 -> module2 -> module3 -> module4 -> module5 -> module6
|
||||
#
|
||||
# If the ModuleGraph is in the 'update' mode
|
||||
# For example,
|
||||
# 'installed' : base, module1, module2, module3
|
||||
# 'to upgrade': module4, module6
|
||||
# 'to install': module5
|
||||
# the updating order is
|
||||
# phase 0: base
|
||||
# phase 1: module1 -> module2 -> module3 -> module4 -> module6
|
||||
# phase 2: module5
|
||||
#
|
||||
# In summary:
|
||||
# phase 0: base
|
||||
# phase odd: (modules: 1. don't need init; 2. all depends modules have been loaded or going to be loaded in this phase)
|
||||
# phase even: (modules: 1. need init; 2. all depends modules have been loaded or going to be loaded in this phase)
|
||||
#
|
||||
#
|
||||
# Test modules
|
||||
# For a module starting with 'test_', we want it to be loaded right after its last loaded dependency in the 'load' mode,
|
||||
# let's call that module 'xxx'.
|
||||
# Therefore, the depth will be 'xxx.depth' and the name will be prefixed by 'xxx ' as its order_name.
|
||||
#
|
||||
#
|
||||
# Corner case
|
||||
# Sometimes the dependency may be changed for sake of upgrade
|
||||
# For example
|
||||
# BEFORE UPGRADE UPGRADING
|
||||
#
|
||||
# +---------+ +---------+
|
||||
# | base | | base |
|
||||
# +---------+ +---------+
|
||||
# ^ installed ^ to upgrade
|
||||
# | |
|
||||
# | |
|
||||
# +---------+ +---------+
|
||||
# | module1 | | module1 | <-----+
|
||||
# +---------+ +---------+ |
|
||||
# ^ installed ^ to upgrade |
|
||||
# | ==> | |
|
||||
# | | |
|
||||
# +---------+ +---------+ +---------+
|
||||
# | module2 | | module2 | | module3 |
|
||||
# +---------+ +---------+ +---------+
|
||||
# ^ installed ^ to upgrade ^ to install
|
||||
# | | |
|
||||
# | | |
|
||||
# +---------+ +---------+ |
|
||||
# | module4 | | module4 | ------+
|
||||
# +---------+ +---------+
|
||||
# installed to upgrade
|
||||
#
|
||||
# Because of the new dependency module4 -> module3
|
||||
# The module3 will be marked 'to install' while upgrading, and module4 should be loaded after module3
|
||||
# As a result, the updating order is
|
||||
# phase 0: base
|
||||
# phase 1: module1 -> module2
|
||||
# phase 2: module3
|
||||
# phase 3: module4
|
||||
|
||||
|
||||
class ModuleNode:
|
||||
"""
|
||||
Loading and upgrade info for an Odoo module
|
||||
"""
|
||||
def __init__(self, name: str, module_graph: ModuleGraph) -> None:
|
||||
# manifest data
|
||||
self.name: str = name
|
||||
# for performance reasons, use the cached value to avoid deepcopy; it is
|
||||
# acceptable in this context since we don't modify it
|
||||
manifest = Manifest.for_addon(name, display_warning=False)
|
||||
if manifest is not None:
|
||||
manifest.manifest_cached # parse the manifest now
|
||||
self.manifest: Mapping = manifest or {}
|
||||
|
||||
# ir_module_module data # column_name
|
||||
self.id: int = 0 # id
|
||||
self.state: STATES = 'uninstalled' # state
|
||||
self.demo: bool = False # demo
|
||||
self.installed_version: str | None = None # latest_version (attention: Incorrect field names !! in ir_module.py)
|
||||
|
||||
# info for upgrade
|
||||
self.load_state: STATES = 'uninstalled' # the state when added to module_graph
|
||||
self.load_version: str | None = None # the version when added to module_graph
|
||||
|
||||
# dependency
|
||||
self.depends: OrderedSet[ModuleNode] = OrderedSet()
|
||||
self.module_graph: ModuleGraph = module_graph
|
||||
|
||||
@functools.cached_property
|
||||
def order_name(self) -> str:
|
||||
if self.name.startswith('test_'):
|
||||
# The 'space' was chosen because it's smaller than any character that can be used by the module name.
|
||||
last_installed_dependency = max(self.depends, key=lambda m: (m.depth, m.order_name))
|
||||
return last_installed_dependency.order_name + ' ' + self.name
|
||||
|
||||
return self.name
|
||||
|
||||
@functools.cached_property
|
||||
def depth(self) -> int:
|
||||
""" Return the longest distance from self to module 'base' along dependencies. """
|
||||
if self.name.startswith('test_'):
|
||||
last_installed_dependency = max(self.depends, key=lambda m: (m.depth, m.order_name))
|
||||
return last_installed_dependency.depth
|
||||
|
||||
return max(module.depth for module in self.depends) + 1 if self.depends else 0
|
||||
|
||||
@functools.cached_property
|
||||
def phase(self) -> int:
|
||||
if self.name == 'base':
|
||||
return 0
|
||||
|
||||
if self.module_graph.mode == 'load':
|
||||
return 1
|
||||
|
||||
def not_in_the_same_phase(module: ModuleNode, dependency: ModuleNode) -> bool:
|
||||
return (module.state == 'to install') ^ (dependency.state == 'to install')
|
||||
|
||||
return max(
|
||||
dependency.phase
|
||||
+ (1 if not_in_the_same_phase(self, dependency) else 0)
|
||||
+ (1 if dependency.name == 'base' else 0)
|
||||
for dependency in self.depends
|
||||
)
|
||||
|
||||
@property
|
||||
def demo_installable(self) -> bool:
|
||||
return all(p.demo for p in self.depends)
|
||||
|
||||
|
||||
class ModuleGraph:
|
||||
"""
|
||||
Sorted Odoo modules ordered by (module.phase, module.depth, module.name)
|
||||
"""
|
||||
|
||||
def __init__(self, cr: BaseCursor, mode: Literal['load', 'update'] = 'load') -> None:
|
||||
# mode 'load': for simply loading modules without updating them
|
||||
# mode 'update': for loading and updating modules
|
||||
self.mode: Literal['load', 'update'] = mode
|
||||
self._modules: dict[str, ModuleNode] = {}
|
||||
self._cr: BaseCursor = cr
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
return name in self._modules
|
||||
|
||||
def __getitem__(self, name: str) -> ModuleNode:
|
||||
return self._modules[name]
|
||||
|
||||
def __iter__(self) -> Iterator[ModuleNode]:
|
||||
return iter(sorted(self._modules.values(), key=lambda p: (p.phase, p.depth, p.order_name)))
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._modules)
|
||||
|
||||
def extend(self, names: Collection[str]) -> None:
|
||||
for module in self._modules.values():
|
||||
reset_cached_properties(module)
|
||||
|
||||
names = [name for name in names if name not in self._modules]
|
||||
|
||||
for name in names:
|
||||
module = self._modules[name] = ModuleNode(name, self)
|
||||
if not module.manifest.get('installable'):
|
||||
if name in self._imported_modules:
|
||||
self._remove(name, log_dependents=False)
|
||||
else:
|
||||
_logger.warning('module %s: not installable, skipped', name)
|
||||
self._remove(name)
|
||||
|
||||
self._update_depends(names)
|
||||
self._update_depth(names)
|
||||
self._update_from_database(names)
|
||||
|
||||
@functools.cached_property
|
||||
def _imported_modules(self) -> OrderedSet[str]:
|
||||
result = ['studio_customization']
|
||||
if column_exists(self._cr, 'ir_module_module', 'imported'):
|
||||
self._cr.execute('SELECT name FROM ir_module_module WHERE imported')
|
||||
result += [m[0] for m in self._cr.fetchall()]
|
||||
return OrderedSet(result)
|
||||
|
||||
def _update_depends(self, names: Iterable[str]) -> None:
|
||||
for name in names:
|
||||
if module := self._modules.get(name):
|
||||
depends = module.manifest['depends']
|
||||
try:
|
||||
module.depends = OrderedSet(self._modules[dep] for dep in depends)
|
||||
except KeyError:
|
||||
_logger.info('module %s: some depends are not loaded, skipped', name)
|
||||
self._remove(name)
|
||||
|
||||
def _update_depth(self, names: Iterable[str]) -> None:
|
||||
for name in names:
|
||||
if module := self._modules.get(name):
|
||||
try:
|
||||
module.depth
|
||||
except RecursionError:
|
||||
_logger.warning('module %s: in a dependency loop, skipped', name)
|
||||
self._remove(name)
|
||||
|
||||
def _update_from_database(self, names: Iterable[str]) -> None:
|
||||
names = tuple(name for name in names if name in self._modules)
|
||||
if not names:
|
||||
return
|
||||
# update modules with values from the database (if exist)
|
||||
query = '''
|
||||
SELECT name, id, state, demo, latest_version AS installed_version
|
||||
FROM ir_module_module
|
||||
WHERE name IN %s
|
||||
'''
|
||||
self._cr.execute(query, [names])
|
||||
for name, id_, state, demo, installed_version in self._cr.fetchall():
|
||||
if state == 'uninstallable':
|
||||
_logger.warning('module %s: not installable, skipped', name)
|
||||
self._remove(name)
|
||||
continue
|
||||
if self.mode == 'load' and state in ['to install', 'uninstalled']:
|
||||
_logger.info('module %s: not installed, skipped', name)
|
||||
self._remove(name)
|
||||
continue
|
||||
if name not in self._modules:
|
||||
# has been recursively removed for sake of not installable or not installed
|
||||
continue
|
||||
module = self._modules[name]
|
||||
module.id = id_
|
||||
module.state = state
|
||||
module.demo = demo
|
||||
module.installed_version = installed_version
|
||||
module.load_version = installed_version
|
||||
module.load_state = state
|
||||
|
||||
def _remove(self, name: str, log_dependents: bool = True) -> None:
|
||||
module = self._modules.pop(name)
|
||||
for another, another_module in list(self._modules.items()):
|
||||
if module in another_module.depends and another_module.name in self._modules:
|
||||
if log_dependents:
|
||||
_logger.info('module %s: its direct/indirect dependency is skipped, skipped', another)
|
||||
self._remove(another)
|
||||
|
|
@ -1,14 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import typing
|
||||
from contextlib import suppress
|
||||
|
||||
import odoo
|
||||
import logging
|
||||
from odoo.tools.misc import file_open
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Iterator
|
||||
|
||||
from odoo.sql_db import Cursor
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def get_installed_modules(cursor):
|
||||
|
||||
def get_installed_modules(cursor: Cursor) -> list[str]:
|
||||
cursor.execute('''
|
||||
SELECT name
|
||||
FROM ir_module_module
|
||||
|
|
@ -16,15 +23,17 @@ def get_installed_modules(cursor):
|
|||
''')
|
||||
return [result[0] for result in cursor.fetchall()]
|
||||
|
||||
def get_neutralization_queries(modules):
|
||||
|
||||
def get_neutralization_queries(modules: Iterable[str]) -> Iterator[str]:
|
||||
# neutralization for each module
|
||||
for module in modules:
|
||||
filename = f'{module}/data/neutralize.sql'
|
||||
with suppress(FileNotFoundError):
|
||||
with odoo.tools.misc.file_open(filename) as file:
|
||||
with file_open(filename) as file:
|
||||
yield file.read().strip()
|
||||
|
||||
def neutralize_database(cursor):
|
||||
|
||||
def neutralize_database(cursor: Cursor) -> None:
|
||||
installed_modules = get_installed_modules(cursor)
|
||||
queries = get_neutralization_queries(installed_modules)
|
||||
for query in queries:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,3 @@
|
|||
# ruff: noqa: F401
|
||||
# Exposed here so that exist code is unaffected.
|
||||
from odoo.orm.registry import DummyRLock, Registry, _REGISTRY_CACHES, _CACHES_BY_KEY
|
||||
Loading…
Add table
Add a link
Reference in a new issue