Coverage for adhoc-cicd-odoo-odoo / odoo / orm / fields_numeric.py: 87%

161 statements  

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

1from __future__ import annotations 

2 

3import typing 

4from operator import attrgetter 

5from xmlrpc.client import MAXINT # TODO change this 

6 

7from odoo.exceptions import AccessError 

8from odoo.tools import float_compare, float_is_zero, float_repr, float_round 

9from odoo.tools.misc import SENTINEL, Sentinel 

10 

11from .fields import Field 

12 

13if typing.TYPE_CHECKING: 

14 from .types import BaseModel, Environment 

15 

16 

17class Integer(Field[int]): 

18 """ Encapsulates an :class:`int`. """ 

19 type = 'integer' 

20 _column_type = ('int4', 'int4') 

21 falsy_value = 0 

22 

23 aggregator = 'sum' 

24 

25 def _get_attrs(self, model_class, name): 

26 res = super()._get_attrs(model_class, name) 

27 # The default aggregator is None for sequence fields 

28 if 'aggregator' not in res and name == 'sequence': 

29 res['aggregator'] = None 

30 return res 

31 

32 def convert_to_column(self, value, record, values=None, validate=True): 

33 return int(value or 0) 

34 

35 def convert_to_cache(self, value, record, validate=True): 

36 if isinstance(value, dict): 36 ↛ 38line 36 didn't jump to line 38 because the condition on line 36 was never true

37 # special case, when an integer field is used as inverse for a one2many 

38 return value.get('id', None) 

39 return int(value or 0) 

40 

41 def convert_to_record(self, value, record): 

42 return value or 0 

43 

44 def convert_to_read(self, value, record, use_display_name=True): 

45 # Integer values greater than 2^31-1 are not supported in pure XMLRPC, 

46 # so we have to pass them as floats :-( 

47 if value and value > MAXINT: 47 ↛ 48line 47 didn't jump to line 48 because the condition on line 47 was never true

48 return float(value) 

49 return value 

50 

51 def _update_inverse(self, records: BaseModel, value: BaseModel): 

52 self._update_cache(records, value.id or 0) 

53 

54 def convert_to_export(self, value, record): 

55 if value or value == 0: 

56 return value 

57 return '' 

58 

59 

60class Float(Field[float]): 

61 """ Encapsulates a :class:`float`. 

62 

63 The precision digits are given by the (optional) ``digits`` attribute. 

64 

65 :param digits: a pair (total, decimal) or a string referencing a 

66 :class:`~odoo.addons.base.models.decimal_precision.DecimalPrecision` record name. 

67 :type digits: tuple(int,int) or str 

68 

69 :param min_display_digits: An int or a string referencing a 

70 :class:`~odoo.addons.base.models.decimal_precision.DecimalPrecision` record name. 

71 Represents the minimum number of decimal digits to display in the UI. 

72 So if it's equal to 3: 

73 - `3.1` will be shown as `'3.100'`. 

74 - `3.1234` will be shown as `'3.1234'`. 

75 :type min_display_digits: int or str 

76 

77 When a float is a quantity associated with an unit of measure, it is important 

78 to use the right tool to compare or round values with the correct precision. 

79 

80 The Float class provides some static methods for this purpose: 

81 

82 :func:`~odoo.fields.Float.round()` to round a float with the given precision. 

83 :func:`~odoo.fields.Float.is_zero()` to check if a float equals zero at the given precision. 

84 :func:`~odoo.fields.Float.compare()` to compare two floats at the given precision. 

85 

86 .. admonition:: Example 

87 

88 To round a quantity with the precision of the unit of measure:: 

89 

90 fields.Float.round(self.product_uom_qty, precision_rounding=self.product_uom_id.rounding) 

91 

92 To check if the quantity is zero with the precision of the unit of measure:: 

93 

94 fields.Float.is_zero(self.product_uom_qty, precision_rounding=self.product_uom_id.rounding) 

95 

96 To compare two quantities:: 

97 

98 field.Float.compare(self.product_uom_qty, self.qty_done, precision_rounding=self.product_uom_id.rounding) 

99 

100 The compare helper uses the __cmp__ semantics for historic purposes, therefore 

101 the proper, idiomatic way to use this helper is like so: 

102 

103 if result == 0, the first and second floats are equal 

104 if result < 0, the first float is lower than the second 

105 if result > 0, the first float is greater than the second 

106 """ 

107 

108 type = 'float' 

109 _digits: str | tuple[int, int] | None = None # digits argument passed to class initializer 

110 _min_display_digits: str | int | None = None 

111 falsy_value = 0.0 

112 aggregator = 'sum' 

113 

114 def __init__( 

115 self, 

116 string: str | Sentinel = SENTINEL, 

117 digits: str | tuple[int, int] | typing.Literal[0, False] | Sentinel | None = SENTINEL, 

118 min_display_digits: str | int | Sentinel | None = SENTINEL, 

119 **kwargs, 

120 ): 

121 if digits is SENTINEL and min_display_digits is not SENTINEL: 

122 digits = False 

123 super().__init__(string=string, _digits=digits, _min_display_digits=min_display_digits, **kwargs) 

124 

125 @property 

126 def _column_type(self): 

127 # Explicit support for "falsy" digits (0, False) to indicate a NUMERIC 

128 # field with no fixed precision. The values are saved in the database 

129 # with all significant digits. 

130 # FLOAT8 type is still the default when there is no precision because it 

131 # is faster for most operations (sums, etc.) 

132 return ('numeric', 'numeric') if self._digits is not None else \ 

133 ('float8', 'double precision') 

134 

135 def get_digits(self, env: Environment) -> tuple[int, int] | None: 

136 if isinstance(self._digits, str): 

137 precision = env['decimal.precision'].precision_get(self._digits) 

138 return 16, precision 

139 else: 

140 return self._digits 

141 

142 def get_min_display_digits(self, env): 

143 if isinstance(self._min_display_digits, str): 

144 return env['decimal.precision'].precision_get(self._min_display_digits) 

145 return self._min_display_digits 

146 

147 _related__digits = property(attrgetter('_digits')) 

148 

149 def _description_digits(self, env: Environment) -> tuple[int, int] | None: 

150 return self.get_digits(env) 

151 

152 def _description_min_display_digits(self, env): 

153 return self.get_min_display_digits(env) 

154 

155 def convert_to_column(self, value, record, values=None, validate=True): 

156 value_float = value = float(value or 0.0) 

157 if digits := self.get_digits(record.env): 

158 _precision, scale = digits 

159 value_float = float_round(value, precision_digits=scale) 

160 value = float_repr(value_float, precision_digits=scale) 

161 if self.company_dependent: 

162 return value_float 

163 return value 

164 

165 def convert_to_cache(self, value, record, validate=True): 

166 # apply rounding here, otherwise value in cache may be wrong! 

167 value = float(value or 0.0) 

168 digits = self.get_digits(record.env) 

169 return float_round(value, precision_digits=digits[1]) if digits else value 

170 

171 def convert_to_record(self, value, record): 

172 return value or 0.0 

173 

174 def convert_to_export(self, value, record): 

175 if value or value == 0.0: 

176 return value 

177 return '' 

178 

179 round = staticmethod(float_round) 

180 is_zero = staticmethod(float_is_zero) 

181 compare = staticmethod(float_compare) 

182 

183 

184class Monetary(Field[float]): 

185 """ Encapsulates a :class:`float` expressed in a given 

186 :class:`res_currency<odoo.addons.base.models.res_currency.Currency>`. 

187 

188 The decimal precision and currency symbol are taken from the ``currency_field`` attribute. 

189 

190 :param str currency_field: name of the :class:`Many2one` field 

191 holding the :class:`res_currency <odoo.addons.base.models.res_currency.Currency>` 

192 this monetary field is expressed in (default: `\'currency_id\'`) 

193 """ 

194 type = 'monetary' 

195 write_sequence = 10 

196 _column_type = ('numeric', 'numeric') 

197 falsy_value = 0.0 

198 

199 currency_field: Field | None = None 

200 aggregator = 'sum' 

201 

202 def __init__(self, string: str | Sentinel = SENTINEL, currency_field: str | Sentinel = SENTINEL, **kwargs): 

203 super().__init__(string=string, currency_field=currency_field, **kwargs) 

204 

205 def _description_currency_field(self, env: Environment) -> str | None: 

206 return self.get_currency_field(env[self.model_name]) 

207 

208 def _description_aggregator(self, env: Environment): 

209 model = env[self.model_name] 

210 query = model._as_query(ordered=False) 

211 currency_field_name = self.get_currency_field(model) 

212 currency_field = model._fields[currency_field_name] 

213 # The currency field needs to be aggregable too 

214 if not currency_field.column_type or not currency_field.store: 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true

215 try: 

216 model._read_group_select(f"{currency_field_name}:array_agg_distinct", query) 

217 except (ValueError, AccessError): 

218 return None 

219 

220 return super()._description_aggregator(env) 

221 

222 def get_currency_field(self, model: BaseModel) -> str | None: 

223 """ Return the name of the currency field. """ 

224 return self.currency_field or ( 

225 'currency_id' if 'currency_id' in model._fields else 

226 'x_currency_id' if 'x_currency_id' in model._fields else 

227 None 

228 ) 

229 

230 def setup_nonrelated(self, model): 

231 super().setup_nonrelated(model) 

232 assert self.get_currency_field(model) in model._fields, \ 

233 "Field %s with unknown currency_field %r" % (self, self.get_currency_field(model)) 

234 

235 def setup_related(self, model): 

236 super().setup_related(model) 

237 if self.inherited: 

238 self.currency_field = self.related_field.get_currency_field(model.env[self.related_field.model_name]) 

239 assert self.get_currency_field(model) in model._fields, \ 

240 "Field %s with unknown currency_field %r" % (self, self.get_currency_field(model)) 

241 

242 def convert_to_column_insert(self, value, record, values=None, validate=True): 

243 # retrieve currency from values or record 

244 currency_field_name = self.get_currency_field(record) 

245 currency_field = record._fields[currency_field_name] 

246 if values and currency_field_name in values: 

247 dummy = record.new({currency_field_name: values[currency_field_name]}) 

248 currency = dummy[currency_field_name] 

249 elif values and currency_field.related and currency_field.related.split('.')[0] in values: 

250 related_field_name = currency_field.related.split('.')[0] 

251 dummy = record.new({related_field_name: values[related_field_name]}) 

252 currency = dummy[currency_field_name] 

253 else: 

254 # Note: this is wrong if 'record' is several records with different 

255 # currencies, which is functional nonsense and should not happen 

256 # BEWARE: do not prefetch other fields, because 'value' may be in 

257 # cache, and would be overridden by the value read from database! 

258 currency = record[:1].sudo().with_context(prefetch_fields=False)[currency_field_name] 

259 currency = currency.with_env(record.env) 

260 

261 value = float(value or 0.0) 

262 if currency: 

263 return float_repr(currency.round(value), currency.decimal_places) 

264 return value 

265 

266 def convert_to_cache(self, value, record, validate=True): 

267 # cache format: float 

268 value = float(value or 0.0) 

269 if value and validate: 

270 # FIXME @rco-odoo: currency may not be already initialized if it is 

271 # a function or related field! 

272 # BEWARE: do not prefetch other fields, because 'value' may be in 

273 # cache, and would be overridden by the value read from database! 

274 currency_field = self.get_currency_field(record) 

275 currency = record.sudo().with_context(prefetch_fields=False)[currency_field] 

276 if len(currency) > 1: 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true

277 raise ValueError("Got multiple currencies while assigning values of monetary field %s" % str(self)) 

278 elif currency: 

279 value = currency.with_env(record.env).round(value) 

280 return value 

281 

282 def convert_to_record(self, value, record): 

283 return value or 0.0 

284 

285 def convert_to_read(self, value, record, use_display_name=True): 

286 return value 

287 

288 def convert_to_write(self, value, record): 

289 return value 

290 

291 def convert_to_export(self, value, record): 

292 if value or value == 0.0: 

293 return value 

294 return '' 

295 

296 def _filter_not_equal(self, records: BaseModel, cache_value: typing.Any) -> BaseModel: 

297 records = super()._filter_not_equal(records, cache_value) 

298 if not records: 

299 return records 

300 # check that the values were rounded properly when put in cache 

301 # see fix odoo/odoo#177200 (commit 7164d5295904b08ec3a0dc1fb54b217671ff531c) 

302 env = records.env 

303 field_cache = self._get_cache(env) 

304 currency_field = records._fields[self.get_currency_field(records)] 

305 return records.browse( 

306 record_id 

307 for record_id, record_sudo in zip( 

308 records._ids, records.sudo().with_context(prefetch_fields=False) 

309 ) 

310 if not ( 

311 (value := field_cache.get(record_id)) 

312 and (currency := currency_field.__get__(record_sudo)) 

313 and currency.with_env(env).round(value) == cache_value 

314 ) 

315 )