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:15 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:15 +0000
1from __future__ import annotations
3import typing
4from operator import attrgetter
5from xmlrpc.client import MAXINT # TODO change this
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
11from .fields import Field
13if typing.TYPE_CHECKING:
14 from .types import BaseModel, Environment
17class Integer(Field[int]):
18 """ Encapsulates an :class:`int`. """
19 type = 'integer'
20 _column_type = ('int4', 'int4')
21 falsy_value = 0
23 aggregator = 'sum'
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
32 def convert_to_column(self, value, record, values=None, validate=True):
33 return int(value or 0)
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)
41 def convert_to_record(self, value, record):
42 return value or 0
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
51 def _update_inverse(self, records: BaseModel, value: BaseModel):
52 self._update_cache(records, value.id or 0)
54 def convert_to_export(self, value, record):
55 if value or value == 0:
56 return value
57 return ''
60class Float(Field[float]):
61 """ Encapsulates a :class:`float`.
63 The precision digits are given by the (optional) ``digits`` attribute.
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
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
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.
80 The Float class provides some static methods for this purpose:
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.
86 .. admonition:: Example
88 To round a quantity with the precision of the unit of measure::
90 fields.Float.round(self.product_uom_qty, precision_rounding=self.product_uom_id.rounding)
92 To check if the quantity is zero with the precision of the unit of measure::
94 fields.Float.is_zero(self.product_uom_qty, precision_rounding=self.product_uom_id.rounding)
96 To compare two quantities::
98 field.Float.compare(self.product_uom_qty, self.qty_done, precision_rounding=self.product_uom_id.rounding)
100 The compare helper uses the __cmp__ semantics for historic purposes, therefore
101 the proper, idiomatic way to use this helper is like so:
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 """
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'
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)
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')
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
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
147 _related__digits = property(attrgetter('_digits'))
149 def _description_digits(self, env: Environment) -> tuple[int, int] | None:
150 return self.get_digits(env)
152 def _description_min_display_digits(self, env):
153 return self.get_min_display_digits(env)
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
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
171 def convert_to_record(self, value, record):
172 return value or 0.0
174 def convert_to_export(self, value, record):
175 if value or value == 0.0:
176 return value
177 return ''
179 round = staticmethod(float_round)
180 is_zero = staticmethod(float_is_zero)
181 compare = staticmethod(float_compare)
184class Monetary(Field[float]):
185 """ Encapsulates a :class:`float` expressed in a given
186 :class:`res_currency<odoo.addons.base.models.res_currency.Currency>`.
188 The decimal precision and currency symbol are taken from the ``currency_field`` attribute.
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
199 currency_field: Field | None = None
200 aggregator = 'sum'
202 def __init__(self, string: str | Sentinel = SENTINEL, currency_field: str | Sentinel = SENTINEL, **kwargs):
203 super().__init__(string=string, currency_field=currency_field, **kwargs)
205 def _description_currency_field(self, env: Environment) -> str | None:
206 return self.get_currency_field(env[self.model_name])
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
220 return super()._description_aggregator(env)
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 )
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))
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))
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)
261 value = float(value or 0.0)
262 if currency:
263 return float_repr(currency.round(value), currency.decimal_places)
264 return value
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
282 def convert_to_record(self, value, record):
283 return value or 0.0
285 def convert_to_read(self, value, record, use_display_name=True):
286 return value
288 def convert_to_write(self, value, record):
289 return value
291 def convert_to_export(self, value, record):
292 if value or value == 0.0:
293 return value
294 return ''
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 )