19.0 vanilla

This commit is contained in:
Ernad Husremovic 2025-10-03 18:07:25 +02:00
parent 0a7ae8db93
commit 991d2234ca
416 changed files with 646602 additions and 300844 deletions

View file

@ -8,15 +8,19 @@ from functools import partial
from psycopg2 import IntegrityError, OperationalError, errorcodes, errors
import odoo
from odoo.exceptions import UserError, ValidationError, AccessError
from odoo import api, http
from odoo.exceptions import (
AccessDenied,
AccessError,
ConcurrencyError,
UserError,
ValidationError,
)
from odoo.models import BaseModel
from odoo.http import request
from odoo.modules.registry import Registry
from odoo.tools import DotDict, lazy
from odoo.tools.translate import translate_sql_constraint
from odoo.tools import lazy
from . import security
from .server import thread_local
_logger = logging.getLogger(__name__)
@ -25,127 +29,151 @@ PG_CONCURRENCY_EXCEPTIONS_TO_RETRY = (errors.LockNotAvailable, errors.Serializat
MAX_TRIES_ON_CONCURRENCY_FAILURE = 5
def get_public_method(model, name):
class Params:
"""Representation of parameters to a function call that can be stringified for display/logging"""
def __init__(self, args, kwargs):
self.args = args
self.kwargs = kwargs
def __str__(self):
params = [repr(arg) for arg in self.args]
params.extend(f"{key}={value!r}" for key, value in sorted(self.kwargs.items()))
return ', '.join(params)
def get_public_method(model: BaseModel, name: str):
""" Get the public unbound method from a model.
When the method does not exist or is inaccessible, raise appropriate errors.
Accessible methods are public (in sense that python defined it:
not prefixed with "_") and are not decorated with `@api.private`.
"""
assert isinstance(model, BaseModel), f"{model!r} is not a BaseModel for {name}"
assert isinstance(model, BaseModel)
e = f"Private methods (such as '{model._name}.{name}') cannot be called remotely."
if name.startswith('_'):
raise AccessError(e)
cls = type(model)
method = getattr(cls, name, None)
if not callable(method):
raise AttributeError(f"The method '{model._name}.{name}' does not exist") # noqa: TRY004
for mro_cls in cls.mro():
cla_method = getattr(mro_cls, name, None)
if not cla_method:
if not (cla_method := getattr(mro_cls, name, None)):
continue
if name.startswith('_') or getattr(cla_method, '_api_private', False):
raise AccessError(f"Private methods (such as '{model._name}.{name}') cannot be called remotely.")
if getattr(cla_method, '_api_private', False):
raise AccessError(e)
return method
def call_kw(model: BaseModel, name: str, args: list, kwargs: Mapping):
""" Invoke the given method ``name`` on the recordset ``model``.
Private methods cannot be called, only ones returned by `get_public_method`.
"""
method = get_public_method(model, name)
# get the records and context
if getattr(method, '_api_model', False):
# @api.model -> no ids
recs = model
else:
ids, args = args[0], args[1:]
recs = model.browse(ids)
# altering kwargs is a cause of errors, for instance when retrying a request
# after a serialization error: the retry is done without context!
kwargs = dict(kwargs)
context = kwargs.pop('context', None) or {}
recs = recs.with_context(context)
# call
_logger.debug("call %s.%s(%s)", recs, method.__name__, Params(args, kwargs))
result = method(recs, *args, **kwargs)
# adapt the result
if name == "create":
# special case for method 'create'
result = result.id if isinstance(args[0], Mapping) else result.ids
elif isinstance(result, BaseModel):
result = result.ids
return result
def dispatch(method, params):
db, uid, passwd = params[0], int(params[1]), params[2]
security.check(db, uid, passwd)
db, uid, passwd, model, method_, *args = params
uid = int(uid)
if not passwd:
raise AccessDenied
# access checked once we open a cursor
threading.current_thread().dbname = db
threading.current_thread().uid = uid
registry = Registry(db).check_signaling()
with registry.manage_changes():
try:
if method == 'execute':
res = execute(db, uid, *params[3:])
kw = {}
elif method == 'execute_kw':
res = execute_kw(db, uid, *params[3:])
# accept: (args, kw=None)
if len(args) == 1:
args += ({},)
args, kw = args
if kw is None:
kw = {}
else:
raise NameError("Method not available %s" % method)
raise NameError(f"Method not available {method}") # noqa: TRY301
with registry.cursor() as cr:
api.Environment(cr, api.SUPERUSER_ID, {})['res.users']._check_uid_passwd(uid, passwd)
res = execute_cr(cr, uid, model, method_, args, kw)
registry.signal_changes()
except Exception:
registry.reset_changes()
raise
return res
def execute_cr(cr, uid, obj, method, *args, **kw):
def execute_cr(cr, uid, obj, method, args, kw):
# clean cache etc if we retry the same transaction
cr.reset()
env = odoo.api.Environment(cr, uid, {})
env = api.Environment(cr, uid, {})
env.transaction.default_env = env # ensure this is the default env for the call
recs = env.get(obj)
if recs is None:
raise UserError(env._("Object %s doesn't exist", obj))
get_public_method(recs, method) # Don't use the result, call_kw will redo the getattr
result = retrying(partial(odoo.api.call_kw, recs, method, args, kw), env)
raise UserError(f"Object {obj} doesn't exist") # pylint: disable=missing-gettext
thread_local.rpc_model_method = f'{obj}.{method}'
result = retrying(partial(call_kw, recs, method, args, kw), env)
# force evaluation of lazy values before the cursor is closed, as it would
# error afterwards if the lazy isn't already evaluated (and cached)
for l in _traverse_containers(result, lazy):
_0 = l._value
if result is None:
_logger.info('The method %s of the object %s cannot return `None`!', method, obj)
return result
def execute_kw(db, uid, obj, method, args, kw=None):
return execute(db, uid, obj, method, *args, **kw or {})
def execute(db, uid, obj, method, *args, **kw):
# TODO could be conditionnaly readonly as in _call_kw_readonly
with Registry(db).cursor() as cr:
res = execute_cr(cr, uid, obj, method, *args, **kw)
if res is None:
_logger.info('The method %s of the object %s can not return `None`!', method, obj)
return res
def _as_validation_error(env, exc):
""" Return the IntegrityError encapsuled in a nice ValidationError """
unknown = env._('Unknown')
model = DotDict({'_name': 'unknown', '_description': unknown})
field = DotDict({'name': 'unknown', 'string': unknown})
for _name, rclass in env.registry.items():
if exc.diag.table_name == rclass._table:
model = rclass
field = model._fields.get(exc.diag.column_name) or field
break
match exc:
case errors.NotNullViolation():
return ValidationError(env._(
"The operation cannot be completed:\n"
"- Create/update: a mandatory field is not set.\n"
"- Delete: another model requires the record being deleted."
" If possible, archive it instead.\n\n"
"Model: %(model_name)s (%(model_tech_name)s)\n"
"Field: %(field_name)s (%(field_tech_name)s)\n",
model_name=model._description,
model_tech_name=model._name,
field_name=field.string,
field_tech_name=field.name,
))
case errors.ForeignKeyViolation():
return ValidationError(env._(
"The operation cannot be completed: another model requires "
"the record being deleted. If possible, archive it instead.\n\n"
"Model: %(model_name)s (%(model_tech_name)s)\n"
"Constraint: %(constraint)s\n",
model_name=model._description,
model_tech_name=model._name,
constraint=exc.diag.constraint_name,
))
if exc.diag.constraint_name in env.registry._sql_constraints:
return ValidationError(env._(
"The operation cannot be completed: %s",
translate_sql_constraint(env.cr, exc.diag.constraint_name, env.context.get('lang', 'en_US'))
))
return ValidationError(env._("The operation cannot be completed: %s", exc.args[0]))
def retrying(func, env):
"""
Call ``func`` until the function returns without serialisation
error. A serialisation error occurs when two requests in independent
cursors perform incompatible changes (such as writing different
values on a same record). By default, it retries up to 5 times.
Call ``func``in a loop until the SQL transaction commits with no
serialisation error. It rollbacks the transaction in between calls.
A serialisation error occurs when two independent transactions
attempt to commit incompatible changes such as writing different
values on a same record. The first transaction to commit works, the
second is canceled with a :class:`psycopg2.errors.SerializationFailure`.
This function intercepts those serialization errors, rollbacks the
transaction, reset things that might have been modified, waits a
random bit, and then calls the function again.
It calls the function up to ``MAX_TRIES_ON_CONCURRENCY_FAILURE`` (5)
times. The time it waits between calls is random with an exponential
backoff: ``random.uniform(0.0, 2 ** i)`` where ``i`` is the n° of
the current attempt and starts at 1.
:param callable func: The function to call, you can pass arguments
using :func:`functools.partial`:.
using :func:`functools.partial`.
:param odoo.api.Environment env: The environment where the registry
and the cursor are taken.
"""
@ -157,12 +185,13 @@ def retrying(func, env):
if not env.cr._closed:
env.cr.flush() # submit the changes to the database
break
except (IntegrityError, OperationalError) as exc:
except (IntegrityError, OperationalError, ConcurrencyError) as exc:
if env.cr._closed:
raise
env.cr.rollback()
env.reset()
env.transaction.reset()
env.registry.reset_changes()
request = http.request
if request:
request.session = request._get_session_and_dbname()[0]
# Rewind files in case of failure
@ -172,22 +201,33 @@ def retrying(func, env):
else:
raise RuntimeError(f"Cannot retry request on input file {filename!r} after serialization failure") from exc
if isinstance(exc, IntegrityError):
raise _as_validation_error(env, exc) from exc
if not isinstance(exc, PG_CONCURRENCY_EXCEPTIONS_TO_RETRY):
model = env['base']
for rclass in env.registry.values():
if exc.diag.table_name == rclass._table:
model = env[rclass._name]
break
message = env._("The operation cannot be completed: %s", model._sql_error_to_message(exc))
raise ValidationError(message) from exc
if isinstance(exc, PG_CONCURRENCY_EXCEPTIONS_TO_RETRY):
error = errorcodes.lookup(exc.pgcode)
elif isinstance(exc, ConcurrencyError):
error = repr(exc)
else:
raise
if not tryleft:
_logger.info("%s, maximum number of tries reached!", errorcodes.lookup(exc.pgcode))
_logger.info("%s, maximum number of tries reached!", error)
raise
wait_time = random.uniform(0.0, 2 ** tryno)
_logger.info("%s, %s tries left, try again in %.04f sec...", errorcodes.lookup(exc.pgcode), tryleft, wait_time)
_logger.info("%s, %s tries left, try again in %.04f sec...", error, tryleft, wait_time)
time.sleep(wait_time)
else:
# handled in the "if not tryleft" case
raise RuntimeError("unreachable")
except Exception:
env.reset()
env.transaction.reset()
env.registry.reset_changes()
raise