Coverage for adhoc-cicd-odoo-odoo / odoo / service / model.py: 12%

160 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-09 18:15 +0000

1# Part of Odoo. See LICENSE file for full copyright and licensing details. 

2import logging 

3import random 

4import threading 

5import time 

6from collections.abc import Mapping, Sequence 

7from functools import partial 

8 

9from psycopg2 import IntegrityError, OperationalError, errorcodes, errors 

10 

11from odoo import api, http 

12from odoo.exceptions import ( 

13 AccessDenied, 

14 AccessError, 

15 ConcurrencyError, 

16 UserError, 

17 ValidationError, 

18) 

19from odoo.models import BaseModel 

20from odoo.modules.registry import Registry 

21from odoo.tools import lazy 

22from odoo.tools.safe_eval import _UNSAFE_ATTRIBUTES 

23 

24from .server import thread_local 

25 

26_logger = logging.getLogger(__name__) 

27 

28PG_CONCURRENCY_ERRORS_TO_RETRY = (errorcodes.LOCK_NOT_AVAILABLE, errorcodes.SERIALIZATION_FAILURE, errorcodes.DEADLOCK_DETECTED) 

29PG_CONCURRENCY_EXCEPTIONS_TO_RETRY = (errors.LockNotAvailable, errors.SerializationFailure, errors.DeadlockDetected) 

30MAX_TRIES_ON_CONCURRENCY_FAILURE = 5 

31 

32 

33class Params: 

34 """Representation of parameters to a function call that can be stringified for display/logging""" 

35 def __init__(self, args, kwargs): 

36 self.args = args 

37 self.kwargs = kwargs 

38 

39 def __str__(self): 

40 params = [repr(arg) for arg in self.args] 

41 params.extend(f"{key}={value!r}" for key, value in sorted(self.kwargs.items())) 

42 return ', '.join(params) 

43 

44 

45def get_public_method(model: BaseModel, name: str): 

46 """ Get the public unbound method from a model. 

47 

48 When the method does not exist or is inaccessible, raise appropriate errors. 

49 Accessible methods are public (in sense that python defined it: 

50 not prefixed with "_") and are not decorated with `@api.private`. 

51 """ 

52 assert isinstance(model, BaseModel) 

53 e = f"Private methods (such as '{model._name}.{name}') cannot be called remotely." 

54 if name.startswith('_') or name in _UNSAFE_ATTRIBUTES: 

55 raise AccessError(e) 

56 

57 cls = type(model) 

58 method = getattr(cls, name, None) 

59 if not callable(method): 

60 raise AttributeError(f"The method '{model._name}.{name}' does not exist") # noqa: TRY004 

61 

62 for mro_cls in cls.mro(): 

63 if not (cla_method := getattr(mro_cls, name, None)): 

64 continue 

65 if getattr(cla_method, '_api_private', False): 

66 raise AccessError(e) 

67 

68 return method 

69 

70 

71def call_kw(model: BaseModel, name: str, args: list, kwargs: Mapping): 

72 """ Invoke the given method ``name`` on the recordset ``model``. 

73 

74 Private methods cannot be called, only ones returned by `get_public_method`. 

75 """ 

76 method = get_public_method(model, name) 

77 

78 # get the records and context 

79 if getattr(method, '_api_model', False): 

80 # @api.model -> no ids 

81 recs = model 

82 else: 

83 ids, args = args[0], args[1:] 

84 recs = model.browse(ids) 

85 

86 # altering kwargs is a cause of errors, for instance when retrying a request 

87 # after a serialization error: the retry is done without context! 

88 kwargs = dict(kwargs) 

89 context = kwargs.pop('context', None) or {} 

90 recs = recs.with_context(context) 

91 

92 # call 

93 _logger.debug("call %s.%s(%s)", recs, method.__name__, Params(args, kwargs)) 

94 result = method(recs, *args, **kwargs) 

95 

96 # adapt the result 

97 if name == "create": 

98 # special case for method 'create' 

99 result = result.id if isinstance(args[0], Mapping) else result.ids 

100 elif isinstance(result, BaseModel): 

101 result = result.ids 

102 

103 return result 

104 

105 

106def dispatch(method, params): 

107 db, uid, passwd, model, method_, *args = params 

108 uid = int(uid) 

109 if not passwd: 

110 raise AccessDenied 

111 # access checked once we open a cursor 

112 

113 threading.current_thread().dbname = db 

114 threading.current_thread().uid = uid 

115 registry = Registry(db).check_signaling() 

116 try: 

117 if method == 'execute': 

118 kw = {} 

119 elif method == 'execute_kw': 

120 # accept: (args, kw=None) 

121 if len(args) == 1: 

122 args += ({},) 

123 args, kw = args 

124 if kw is None: 

125 kw = {} 

126 else: 

127 raise NameError(f"Method not available {method}") # noqa: TRY301 

128 with registry.cursor() as cr: 

129 api.Environment(cr, api.SUPERUSER_ID, {})['res.users']._check_uid_passwd(uid, passwd) 

130 res = execute_cr(cr, uid, model, method_, args, kw) 

131 registry.signal_changes() 

132 except Exception: 

133 registry.reset_changes() 

134 raise 

135 return res 

136 

137 

138def execute_cr(cr, uid, obj, method, args, kw): 

139 # clean cache etc if we retry the same transaction 

140 cr.reset() 

141 env = api.Environment(cr, uid, {}) 

142 env.transaction.default_env = env # ensure this is the default env for the call 

143 recs = env.get(obj) 

144 if recs is None: 

145 raise UserError(f"Object {obj} doesn't exist") # pylint: disable=missing-gettext 

146 thread_local.rpc_model_method = f'{obj}.{method}' 

147 result = retrying(partial(call_kw, recs, method, args, kw), env) 

148 # force evaluation of lazy values before the cursor is closed, as it would 

149 # error afterwards if the lazy isn't already evaluated (and cached) 

150 for l in _traverse_containers(result, lazy): 

151 _0 = l._value 

152 if result is None: 

153 _logger.info('The method %s of the object %s cannot return `None`!', method, obj) 

154 return result 

155 

156 

157def retrying(func, env): 

158 """ 

159 Call ``func``in a loop until the SQL transaction commits with no 

160 serialisation error. It rollbacks the transaction in between calls. 

161 

162 A serialisation error occurs when two independent transactions 

163 attempt to commit incompatible changes such as writing different 

164 values on a same record. The first transaction to commit works, the 

165 second is canceled with a :class:`psycopg2.errors.SerializationFailure`. 

166 

167 This function intercepts those serialization errors, rollbacks the 

168 transaction, reset things that might have been modified, waits a 

169 random bit, and then calls the function again. 

170 

171 It calls the function up to ``MAX_TRIES_ON_CONCURRENCY_FAILURE`` (5) 

172 times. The time it waits between calls is random with an exponential 

173 backoff: ``random.uniform(0.0, 2 ** i)`` where ``i`` is the n° of 

174 the current attempt and starts at 1. 

175 

176 :param callable func: The function to call, you can pass arguments 

177 using :func:`functools.partial`. 

178 :param odoo.api.Environment env: The environment where the registry 

179 and the cursor are taken. 

180 """ 

181 try: 

182 for tryno in range(1, MAX_TRIES_ON_CONCURRENCY_FAILURE + 1): 

183 tryleft = MAX_TRIES_ON_CONCURRENCY_FAILURE - tryno 

184 try: 

185 result = func() 

186 if not env.cr._closed: 

187 env.cr.flush() # submit the changes to the database 

188 break 

189 except (IntegrityError, OperationalError, ConcurrencyError) as exc: 

190 if env.cr._closed: 

191 raise 

192 env.cr.rollback() 

193 env.transaction.reset() 

194 env.registry.reset_changes() 

195 request = http.request 

196 if request: 

197 request.session = request._get_session_and_dbname()[0] 

198 # Rewind files in case of failure 

199 for filename, file in request.httprequest.files.items(): 

200 if hasattr(file, "seekable") and file.seekable(): 

201 file.seek(0) 

202 else: 

203 raise RuntimeError(f"Cannot retry request on input file {filename!r} after serialization failure") from exc 

204 if isinstance(exc, IntegrityError): 

205 model = env['base'] 

206 for rclass in env.registry.values(): 

207 if exc.diag.table_name == rclass._table: 

208 model = env[rclass._name] 

209 break 

210 message = env._("The operation cannot be completed: %s", model._sql_error_to_message(exc)) 

211 raise ValidationError(message) from exc 

212 

213 if isinstance(exc, PG_CONCURRENCY_EXCEPTIONS_TO_RETRY): 

214 error = errorcodes.lookup(exc.pgcode) 

215 elif isinstance(exc, ConcurrencyError): 

216 error = repr(exc) 

217 else: 

218 raise 

219 if not tryleft: 

220 _logger.info("%s, maximum number of tries reached!", error) 

221 raise 

222 

223 wait_time = random.uniform(0.0, 2 ** tryno) 

224 _logger.info("%s, %s tries left, try again in %.04f sec...", error, tryleft, wait_time) 

225 time.sleep(wait_time) 

226 else: 

227 # handled in the "if not tryleft" case 

228 raise RuntimeError("unreachable") 

229 

230 except Exception: 

231 env.transaction.reset() 

232 env.registry.reset_changes() 

233 raise 

234 

235 if not env.cr.closed: 

236 env.cr.commit() # effectively commits and execute post-commits 

237 env.registry.signal_changes() 

238 return result 

239 

240 

241def _traverse_containers(val, type_): 

242 """ Yields atoms filtered by specified ``type_`` (or type tuple), traverses 

243 through standard containers (non-string mappings or sequences) *unless* 

244 they're selected by the type filter 

245 """ 

246 from odoo.models import BaseModel 

247 if isinstance(val, type_): 

248 yield val 

249 elif isinstance(val, (str, bytes, BaseModel)): 

250 return 

251 elif isinstance(val, Mapping): 

252 for k, v in val.items(): 

253 yield from _traverse_containers(k, type_) 

254 yield from _traverse_containers(v, type_) 

255 elif isinstance(val, Sequence): 

256 for v in val: 

257 yield from _traverse_containers(v, type_)