18.0 vanilla

This commit is contained in:
Ernad Husremovic 2025-10-03 18:06:50 +02:00
parent d72e748793
commit 0a7ae8db93
337 changed files with 399651 additions and 232598 deletions

View file

@ -15,7 +15,7 @@ def is_initialized(cr):
The database can be initialized with the 'initialize' function below.
"""
return odoo.tools.table_exists(cr, 'ir_module_module')
return odoo.tools.sql.table_exists(cr, 'ir_module_module')
def initialize(cr):
""" Initialize a database with for the ORM.

View file

@ -22,7 +22,6 @@ from .. import SUPERUSER_ID, api, tools
from .module import adapt_version, initialize_sys_path, load_openerp_module
_logger = logging.getLogger(__name__)
_test_logger = logging.getLogger('odoo.tests')
def load_data(env, idref, mode, kind, package):
@ -275,14 +274,14 @@ def load_module_graph(env, graph, status=None, perform_checks=True,
test_time = test_queries = 0
test_results = None
if tools.config.options['test_enable'] and (needs_update or not updating):
loader = odoo.tests.loader
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)
# Python tests
tests_t0, tests_q0 = time.time(), odoo.sql_db.sql_counter
test_results = loader.run_suite(suite, module_name, global_report=report)
test_results = loader.run_suite(suite, global_report=report)
report.update(test_results)
test_time = time.time() - tests_t0
test_queries = odoo.sql_db.sql_counter - tests_q0
@ -415,11 +414,16 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
for pyfile in tools.config['pre_upgrade_scripts'].split(','):
odoo.modules.migration.exec_script(cr, graph['base'].installed_version, pyfile, 'base', 'pre')
if update_module and odoo.tools.table_exists(cr, 'ir_model_fields'):
if update_module and odoo.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()}
# 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
@ -435,7 +439,7 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
if load_lang:
for lang in load_lang.split(','):
tools.load_language(cr, lang)
tools.translate.load_language(cr, lang)
# STEP 2: Mark other modules to be loaded/updated
if update_module:
@ -597,9 +601,9 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
try:
View._validate_custom_views(model)
except Exception as e:
_logger.warning('invalid custom view(s) for model %s: %s', model, tools.ustr(e))
_logger.warning('invalid custom view(s) for model %s: %s', model, e)
if report.wasSuccessful():
if not registry._assertion_report or registry._assertion_report.wasSuccessful():
_logger.info('Modules loaded.')
else:
_logger.error('At least one test failed when loading the modules.')

View file

@ -2,13 +2,14 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
""" Modules migration handling. """
from collections import defaultdict
import glob
import importlib.util
import inspect
import itertools
import logging
import os
import re
from collections import defaultdict
from os.path import join as opj
import odoo.release as release
@ -220,23 +221,39 @@ class MigrationManager(object):
for pyfile in _get_migration_files(pkg, version, stage):
exec_script(self.cr, installed_version, pyfile, pkg.name, stage, stageformat[stage] % version)
VALID_MIGRATE_PARAMS = list(itertools.product(
['cr', '_cr'],
['version', '_version'],
))
def exec_script(cr, installed_version, pyfile, addon, stage, version=None):
version = version or installed_version
name, ext = os.path.splitext(os.path.basename(pyfile))
if ext.lower() != '.py':
return
mod = None
try:
mod = load_script(pyfile, name)
_logger.info('module %(addon)s: Running migration %(version)s %(name)s' % dict(locals(), name=mod.__name__))
migrate = mod.migrate
except ImportError:
_logger.exception('module %(addon)s: Unable to load %(stage)s-migration file %(file)s' % dict(locals(), file=pyfile))
raise
except AttributeError:
_logger.error('module %(addon)s: Each %(stage)s-migration file must have a "migrate(cr, installed_version)" function' % locals())
else:
migrate(cr, installed_version)
finally:
if mod:
del mod
except ImportError as e:
raise ImportError('module %(addon)s: Unable to load %(stage)s-migration file %(file)s' % dict(locals(), file=pyfile)) from e
if not hasattr(mod, 'migrate'):
raise AttributeError(
'module %(addon)s: Each %(stage)s-migration file must have a "migrate(cr, installed_version)" function, not found in %(file)s' % dict(
locals(),
file=pyfile,
))
try:
sig = inspect.signature(mod.migrate)
except TypeError as e:
raise TypeError("module %(addon)s: `migrate` needs to be a function, got %(migrate)r" % dict(locals(), migrate=mod.migrate)) from e
if not (
tuple(sig.parameters.keys()) in VALID_MIGRATE_PARAMS
and all(p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) for p in sig.parameters.values())
):
raise TypeError("module %(addon)s: `migrate`'s signature should be `(cr, version)`, %(func)s is %(sig)s" % dict(locals(), func=mod.migrate, sig=sig))
_logger.info('module %(addon)s: Running migration %(version)s %(name)s' % dict(locals(), name=mod.__name__)) # noqa: G002
mod.migrate(cr, installed_version)

View file

@ -6,20 +6,35 @@ import collections.abc
import copy
import functools
import importlib
import importlib.metadata
import logging
import os
import pkg_resources
import re
import sys
import traceback
import warnings
from os.path import join as opj, normpath
import odoo
import odoo.tools as tools
import odoo.release as release
from odoo.tools import pycompat
from odoo.tools.misc import file_path
try:
from packaging.requirements import InvalidRequirement, Requirement
except ImportError:
class InvalidRequirement(Exception):
...
class Requirement:
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"
raise Exception(msg)
self.marker = None
self.specifier = None
self.name = pydep
MANIFEST_NAMES = ('__manifest__.py', '__openerp__.py')
README = ['README.rst', 'README.md', 'README.txt']
@ -32,6 +47,7 @@ _DEFAULT_MANIFEST = {
'author': 'Odoo S.A.',
'auto_install': False,
'category': 'Uncategorized',
'cloc_exclude': [],
'configurator_snippets': {}, # website themes
'countries': [],
'data': [],
@ -62,6 +78,17 @@ _DEFAULT_MANIFEST = {
'website': '',
}
# matches field definitions like
# partner_id: base.ResPartner = fields.Many2one
# partner_id = fields.Many2one[base.ResPartner]
TYPED_FIELD_DEFINITION_RE = re.compile(r'''
\b (?P<field_name>\w+) \s*
(:\s*(?P<field_type>[^ ]*))? \s*
= \s*
fields\.(?P<field_class>Many2one|One2many|Many2many)
(\[(?P<type_param>[^\]]+)\])?
''', re.VERBOSE)
_logger = logging.getLogger(__name__)
@ -103,7 +130,7 @@ def initialize_sys_path():
# hook odoo.addons on addons paths
for ad in tools.config['addons_path'].split(','):
ad = os.path.normcase(os.path.abspath(tools.ustr(ad.strip())))
ad = os.path.normcase(os.path.abspath(ad.strip()))
if ad not in odoo.addons.__path__:
odoo.addons.__path__.append(ad)
@ -116,7 +143,7 @@ def initialize_sys_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(tools.ustr(up.strip())))
up = os.path.normcase(os.path.abspath(up.strip()))
if os.path.isdir(up) and up not in upgrade.__path__:
upgrade.__path__.append(up)
@ -155,39 +182,6 @@ def get_module_path(module, downloaded=False, display_warning=True):
_logger.warning('module %s: module not found', module)
return False
def get_module_filetree(module, dir='.'):
warnings.warn(
"Since 16.0: use os.walk or a recursive glob or something",
DeprecationWarning,
stacklevel=2
)
path = get_module_path(module)
if not path:
return False
dir = os.path.normpath(dir)
if dir == '.':
dir = ''
if dir.startswith('..') or (dir and dir[0] == '/'):
raise Exception('Cannot access file outside the module')
files = odoo.tools.osutil.listdir(path, True)
tree = {}
for f in files:
if not f.startswith(dir):
continue
if dir:
f = f[len(dir)+int(not dir.endswith('/')):]
lst = f.split(os.sep)
current = tree
while len(lst) != 1:
current = current.setdefault(lst.pop(0), {})
current[lst.pop(0)] = None
return tree
def get_resource_path(module, *args):
"""Return the full path of a resource of the given module.
@ -372,11 +366,6 @@ def get_manifest(module, mod_path=None):
def _get_manifest_cached(module, mod_path=None):
return load_manifest(module, mod_path)
def load_information_from_description_file(module, mod_path=None):
warnings.warn(
'load_information_from_description_file() is a deprecated '
'alias to get_manifest()', DeprecationWarning, stacklevel=2)
return get_manifest(module, mod_path)
def load_openerp_module(module_name):
""" Load an OpenERP module, if not already loaded.
@ -401,6 +390,24 @@ def load_openerp_module(module_name):
if info['post_load']:
getattr(sys.modules[qualname], info['post_load'])()
except AttributeError as err:
_logger.critical("Couldn't load module %s", module_name)
trace = traceback.format_exc()
match = TYPED_FIELD_DEFINITION_RE.search(trace)
if match and "most likely due to a circular import" in trace:
field_name = match['field_name']
field_class = match['field_class']
field_type = match['field_type'] or match['type_param']
if "." not in field_type:
field_type = f"{module_name}.{field_type}"
raise AttributeError(
f"{err}\n"
"To avoid circular import for the the comodel use the annotation syntax:\n"
f" {field_name}: {field_type} = fields.{field_class}(...)\n"
"and add at the beggining of the file:\n"
" from __future__ import annotations"
).with_traceback(err.__traceback__) from None
raise
except Exception:
_logger.critical("Couldn't load module %s", module_name)
raise
@ -464,21 +471,31 @@ current_test = False
def check_python_external_dependency(pydep):
try:
pkg_resources.get_distribution(pydep)
except pkg_resources.DistributionNotFound as e:
requirement = Requirement(pydep)
except InvalidRequirement as e:
msg = f"{pydep} is an invalid external dependency specification: {e}"
raise Exception(msg) from e
if requirement.marker and not requirement.marker.evaluate():
_logger.debug(
"Ignored external dependency %s because environment markers do not match",
pydep
)
return
try:
version = importlib.metadata.version(requirement.name)
except importlib.metadata.PackageNotFoundError as e:
try:
# keep compatibility with module name but log a warning instead of info
importlib.import_module(pydep)
_logger.info("python external dependency on '%s' does not appear to be a valid PyPI package. Using a PyPI package name is recommended.", pydep)
_logger.warning("python external dependency on '%s' does not appear o be a valid PyPI package. Using a PyPI package name is recommended.", pydep)
return
except ImportError:
# backward compatibility attempt failed
_logger.warning("DistributionNotFound: %s", e)
raise Exception('Python library not installed: %s' % (pydep,))
except pkg_resources.VersionConflict as e:
_logger.warning("VersionConflict: %s", e)
raise Exception('Python library version conflict: %s' % (pydep,))
except Exception as e:
_logger.warning("get_distribution(%s) failed: %s", pydep, e)
raise Exception('Error finding python library %s' % (pydep,))
pass
msg = f"External dependency {pydep} not installed: {e}"
raise Exception(msg) from e
if requirement.specifier and not requirement.specifier.contains(version):
msg = f"External dependency version mismatch: {pydep} (installed: {version})"
raise Exception(msg)
def check_manifest_dependencies(manifest):

View file

@ -4,33 +4,39 @@
""" Models registries.
"""
from collections import defaultdict, deque
from collections.abc import Mapping
from contextlib import closing, contextmanager
from functools import partial
from operator import attrgetter
from __future__ import annotations
import inspect
import logging
import os
import threading
import time
import typing
import warnings
from collections import defaultdict, deque
from collections.abc import Mapping
from contextlib import closing, contextmanager, nullcontext
from functools import partial
from operator import attrgetter
import psycopg2
import odoo
from odoo.modules.db import FunctionStatus
from odoo.osv.expression import get_unaccent_wrapper
from .. import SUPERUSER_ID
from odoo.sql_db import TestCursor
from odoo.tools import (
config, existing_tables, lazy_classproperty,
lazy_property, sql, Collector, OrderedSet, SQL,
format_frame
config, lazy_classproperty,
lazy_property, sql, OrderedSet, SQL,
remove_accents,
)
from odoo.tools.func import locked
from odoo.tools.lru import LRU
from odoo.tools.misc import Collector, format_frame
if typing.TYPE_CHECKING:
from odoo.models import BaseModel
_logger = logging.getLogger(__name__)
_schema = logging.getLogger('odoo.schema')
@ -43,6 +49,7 @@ _REGISTRY_CACHES = {
'routing': 1024, # 2 entries per website
'routing.rewrites': 8192, # url_rewrite entries
'templates.cached_values': 2048, # arbitrary
'groups': 1, # contains all res.groups
}
# cache invalidation dependencies, as follows:
@ -52,8 +59,19 @@ _CACHES_BY_KEY = {
'assets': ('assets', 'templates.cached_values'),
'templates': ('templates', 'templates.cached_values'),
'routing': ('routing', 'routing.rewrites', 'templates.cached_values'),
'groups': ('groups', 'templates', 'templates.cached_values'), # The processing of groups is saved in the view
}
_REPLICA_RETRY_TIME = 20 * 60 # 20 minutes
def _unaccent(x):
if isinstance(x, SQL):
return SQL("unaccent(%s)", x)
if isinstance(x, psycopg2.sql.Composable):
return psycopg2.sql.SQL('unaccent({})').format(x)
return f'unaccent({x})'
class Registry(Mapping):
""" Model registry for a particular database.
@ -82,6 +100,7 @@ class Registry(Mapping):
def __new__(cls, db_name):
""" Return the registry for the given database name."""
assert db_name, "Missing database name"
with cls._lock:
try:
return cls.registries[db_name]
@ -112,7 +131,7 @@ class Registry(Mapping):
odoo.modules.reset_modules_state(db_name)
raise
except Exception:
_logger.exception('Failed to load registry')
_logger.error('Failed to load registry')
del cls.registries[db_name] # pylint: disable=unsupported-delete-operation
raise
@ -124,16 +143,22 @@ class Registry(Mapping):
registry._init = False
registry.ready = True
registry.registry_invalidated = bool(update_module)
registry.signal_changes()
_logger.info("Registry loaded in %.3fs", time.time() - t0)
return registry
def init(self, db_name):
self.models = {} # model name/model instance mapping
self.models: dict[str, type[BaseModel]] = {} # model name/model instance mapping
self._sql_constraints = set()
self._init = True
self._database_translated_fields = () # names of translated fields in database
self._assertion_report = odoo.tests.result.OdooTestResult()
self._database_company_dependent_fields = () # names of company dependent fields in database
if config['test_enable'] or config['test_file']:
from odoo.tests.result import OdooTestResult # noqa: PLC0415
self._assertion_report = OdooTestResult()
else:
self._assertion_report = None
self._fields_by_model = None
self._ordinary_tables = None
self._constraint_queue = deque()
@ -145,7 +170,11 @@ class Registry(Mapping):
self.loaded_xmlids = set()
self.db_name = db_name
self._db = odoo.sql_db.db_connect(db_name)
self._db = odoo.sql_db.db_connect(db_name, readonly=False)
self._db_readonly = None
self._db_readonly_failed_time = None
if config['db_replica_host'] is not False or config['test_enable']: # by default, only use readonly pool if we have a db_replica_host defined. Allows to have an empty replica host for testing
self._db_readonly = odoo.sql_db.db_connect(db_name, readonly=True)
# cursor for test mode; None means "normal" mode
self.test_cr = None
@ -160,6 +189,9 @@ class Registry(Mapping):
self.field_depends_context = Collector()
self.field_inverses = Collector()
# company dependent
self.many2one_company_dependents = Collector() # {model_name: (field1, field2, ...)}
# cache of methods get_field_trigger_tree() and is_modifying_relations()
self._field_trigger_trees = {}
self._is_modifying_relations = {}
@ -179,6 +211,9 @@ class Registry(Mapping):
self.has_unaccent = odoo.modules.db.has_unaccent(cr)
self.has_trigram = odoo.modules.db.has_trigram(cr)
self.unaccent = _unaccent if self.has_unaccent else lambda x: x
self.unaccent_python = remove_accents if self.has_unaccent else lambda x: x
@classmethod
@locked
def delete(cls, db_name):
@ -204,7 +239,7 @@ class Registry(Mapping):
""" Return an iterator over all model names. """
return iter(self.models)
def __getitem__(self, model_name):
def __getitem__(self, model_name: str) -> type[BaseModel]:
""" Return the model with the given name or raise KeyError if it doesn't exist."""
return self.models[model_name]
@ -309,6 +344,7 @@ class Registry(Mapping):
self.field_depends.clear()
self.field_depends_context.clear()
self.field_inverses.clear()
self.many2one_company_dependents.clear()
# do the actual setup
for model in models:
@ -598,7 +634,7 @@ class Registry(Mapping):
""" Create or drop column indexes for the given models. """
expected = [
(sql.make_index_name(Model._table, field.name), Model._table, field, getattr(field, 'unaccent', False))
(sql.make_index_name(Model._table, field.name), Model._table, field)
for model_name in model_names
for Model in [self.models[model_name]]
if Model._auto and not Model._abstract
@ -613,7 +649,7 @@ class Registry(Mapping):
[tuple(row[0] for row in expected)])
existing = dict(cr.fetchall())
for indexname, tablename, field, unaccent in expected:
for indexname, tablename, field in expected:
index = field.index
assert index in ('btree', 'btree_not_null', 'trigram', True, False, None)
if index and indexname not in existing and \
@ -623,19 +659,24 @@ class Registry(Mapping):
if field.translate:
column_expression = f'''(jsonb_path_query_array({column_expression}, '$.*')::text)'''
# add `unaccent` to the trigram index only because the
# trigram indexes are mainly used for (i/=)like search and
# trigram indexes are mainly used for (=)ilike search and
# unaccent is added only in these cases when searching
if unaccent and self.has_unaccent:
if self.has_unaccent == FunctionStatus.INDEXABLE:
column_expression = get_unaccent_wrapper(cr)(column_expression)
else:
warnings.warn(
"PostgreSQL function 'unaccent' is present but not immutable, "
"therefore trigram indexes may not be effective.",
)
if self.has_unaccent == FunctionStatus.INDEXABLE:
column_expression = self.unaccent(column_expression)
elif self.has_unaccent:
warnings.warn(
"PostgreSQL function 'unaccent' is present but not immutable, "
"therefore trigram indexes may not be effective.",
)
expression = f'{column_expression} gin_trgm_ops'
method = 'gin'
where = ''
elif index == 'btree_not_null' and field.company_dependent:
# company dependent condition will use extra
# `AND col IS NOT NULL` to use the index.
expression = f'({column_expression} IS NOT NULL)'
method = 'btree'
where = f'{column_expression} IS NOT NULL'
else: # index in ['btree', 'btree_not_null' True]
expression = f'{column_expression}'
method = 'btree'
@ -706,7 +747,7 @@ class Registry(Mapping):
for name, model in env.registry.items()
if not model._abstract and model._table_query is None
}
missing_tables = set(table2model).difference(existing_tables(cr, table2model))
missing_tables = set(table2model).difference(sql.existing_tables(cr, table2model))
if missing_tables:
missing = {table2model[table] for table in missing_tables}
@ -717,7 +758,7 @@ class Registry(Mapping):
env[name].init()
env.flush_all()
# check again, and log errors if tables are still missing
missing_tables = set(table2model).difference(existing_tables(cr, table2model))
missing_tables = set(table2model).difference(sql.existing_tables(cr, table2model))
for table in missing_tables:
_logger.error("Model %s has no table.", table2model[table])
@ -817,6 +858,8 @@ class Registry(Mapping):
self.registry_sequence, ' '.join('[Cache %s: %s]' % cs for cs in self.cache_sequences.items()))
def get_sequences(self, cr):
assert cr.readonly is False, "can't use replica, sequence data is not replicated"
cache_sequences_query = ', '.join([f'base_cache_signaling_{cache_name}' for cache_name in _CACHES_BY_KEY])
cache_sequences_values_query = ',\n'.join([f'base_cache_signaling_{cache_name}.last_value' for cache_name in _CACHES_BY_KEY])
cr.execute(f"""
@ -827,14 +870,14 @@ class Registry(Mapping):
cache_sequences = dict(zip(_CACHES_BY_KEY, cache_sequences_values))
return registry_sequence, cache_sequences
def check_signaling(self):
def check_signaling(self, cr=None):
""" Check whether the registry has changed, and performs all necessary
operations to update the registry. Return an up-to-date registry.
"""
if self.in_test_mode():
return self
with closing(self.cursor()) as cr:
with nullcontext(cr) if cr is not None else closing(self.cursor()) as cr:
db_registry_sequence, db_cache_sequences = self.get_sequences(cr)
changes = ''
# Check if the model registry must be reloaded
@ -865,20 +908,19 @@ class Registry(Mapping):
def signal_changes(self):
""" Notifies other processes if registry or cache has been invalidated. """
if self.in_test_mode():
if self.registry_invalidated:
self.registry_sequence += 1
for cache_name in self.cache_invalidated or ():
self.cache_sequences[cache_name] += 1
self.registry_invalidated = False
self.cache_invalidated.clear()
if not self.ready:
_logger.warning('Calling signal_changes when registry is not ready is not suported')
return
if self.registry_invalidated:
_logger.info("Registry changed, signaling through the database")
with closing(self.cursor()) as cr:
cr.execute("select nextval('base_registry_signaling')")
self.registry_sequence = cr.fetchone()[0]
# If another process concurrently updates the registry,
# self.registry_sequence will actually be out-of-date,
# and the next call to check_signaling() will detect that and trigger a registry reload.
# otherwise, self.registry_sequence should be equal to cr.fetchone()[0]
self.registry_sequence += 1
# no need to notify cache invalidation in case of registry invalidation,
# because reloading the registry implies starting with an empty cache
@ -887,7 +929,11 @@ class Registry(Mapping):
with closing(self.cursor()) as cr:
for cache_name in self.cache_invalidated:
cr.execute("select nextval(%s)", [f'base_cache_signaling_{cache_name}'])
self.cache_sequences[cache_name] = cr.fetchone()[0]
# If another process concurrently updates the cache,
# self.cache_sequences[cache_name] will actually be out-of-date,
# and the next call to check_signaling() will detect that and trigger cache invalidation.
# otherwise, self.cache_sequences[cache_name] should be equal to cr.fetchone()[0]
self.cache_sequences[cache_name] += 1
self.registry_invalidated = False
self.cache_invalidated.clear()
@ -918,10 +964,11 @@ class Registry(Mapping):
""" Test whether the registry is in 'test' mode. """
return self.test_cr is not None
def enter_test_mode(self, cr):
def enter_test_mode(self, cr, test_readonly_enabled=True):
""" Enter the 'test' mode, where one cursor serves several requests. """
assert self.test_cr is None
self.test_cr = cr
self.test_readonly_enabled = test_readonly_enabled
self.test_lock = threading.RLock()
assert Registry._saved_lock is None
Registry._saved_lock = Registry._lock
@ -931,18 +978,39 @@ class Registry(Mapping):
""" Leave the test mode. """
assert self.test_cr is not None
self.test_cr = None
self.test_lock = None
del self.test_readonly_enabled
del self.test_lock
assert Registry._saved_lock is not None
Registry._lock = Registry._saved_lock
Registry._saved_lock = None
def cursor(self):
def cursor(self, /, readonly=False):
""" Return a new cursor for the database. The cursor itself may be used
as a context manager to commit/rollback and close automatically.
:param readonly: Attempt to acquire a cursor on a replica database.
Acquire a read/write cursor on the primary database in case no
replica exists or that no readonly cursor could be acquired.
"""
if self.test_cr is not None:
# in test mode we use a proxy object that uses 'self.test_cr' underneath
return TestCursor(self.test_cr, self.test_lock, current_test=odoo.modules.module.current_test)
if readonly and not self.test_readonly_enabled:
_logger.info('Explicitly ignoring readonly flag when generating a cursor')
return TestCursor(self.test_cr, self.test_lock, readonly and self.test_readonly_enabled, current_test=odoo.modules.module.current_test)
if readonly and self._db_readonly is not None:
if (
self._db_readonly_failed_time is None
or time.monotonic() > self._db_readonly_failed_time + _REPLICA_RETRY_TIME
):
try:
cr = self._db_readonly.cursor()
self._db_readonly_failed_time = None
return cr
except psycopg2.OperationalError:
self._db_readonly_failed_time = time.monotonic()
_logger.warning("Failed to open a readonly cursor, falling back to read-write cursor for %dmin %dsec", *divmod(_REPLICA_RETRY_TIME, 60))
threading.current_thread().cursor_mode = 'ro->rw'
return self._db.cursor()