Coverage for adhoc-cicd-odoo-odoo / odoo / orm / fields_selection.py: 85%
125 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:05 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:05 +0000
1from __future__ import annotations
3import typing
4from collections import defaultdict
6from odoo.tools.misc import ReadonlyDict, SENTINEL, Sentinel, merge_sequences
7from odoo.tools.sql import pg_varchar
9from .fields import Field, _logger, determine, resolve_mro
11if typing.TYPE_CHECKING:
12 from collections.abc import Callable
14 from .types import BaseModel
16 SelectValue = tuple[str, str] # (value, string)
17 OnDeletePolicy = str | Callable[[BaseModel], None]
20class Selection(Field[str | typing.Literal[False]]):
21 """ Encapsulates an exclusive choice between different values.
23 :param selection: specifies the possible values for this field.
24 It is given as either a list of pairs ``(value, label)``, or a model
25 method, or a method name.
26 :type selection: list(tuple(str,str)) or callable or str
28 :param selection_add: provides an extension of the selection in the case
29 of an overridden field. It is a list of pairs ``(value, label)`` or
30 singletons ``(value,)``, where singleton values must appear in the
31 overridden selection. The new values are inserted in an order that is
32 consistent with the overridden selection and this list::
34 selection = [('a', 'A'), ('b', 'B')]
35 selection_add = [('c', 'C'), ('b',)]
36 > result = [('a', 'A'), ('c', 'C'), ('b', 'B')]
37 :type selection_add: list(tuple(str,str))
39 :param ondelete: provides a fallback mechanism for any overridden
40 field with a selection_add. It is a dict that maps every option
41 from the selection_add to a fallback action.
43 This fallback action will be applied to all records whose
44 selection_add option maps to it.
46 The actions can be any of the following:
47 - 'set null' -- the default, all records with this option
48 will have their selection value set to False.
49 - 'cascade' -- all records with this option will be
50 deleted along with the option itself.
51 - 'set default' -- all records with this option will be
52 set to the default of the field definition
53 - 'set VALUE' -- all records with this option will be
54 set to the given value
55 - <callable> -- a callable whose first and only argument will be
56 the set of records containing the specified Selection option,
57 for custom processing
59 The attribute ``selection`` is mandatory except in the case of
60 ``related`` or extended fields.
61 """
62 type = 'selection'
63 _column_type = ('varchar', pg_varchar())
65 selection: list[SelectValue] | str | Callable[[BaseModel], list[SelectValue]] | None = None # [(value, string), ...], function or method name
66 validate: bool = True # whether validating upon write
67 ondelete: dict[str, OnDeletePolicy] | None = None # {value: policy} (what to do when value is deleted)
69 def __init__(self, selection=SENTINEL, string: str | Sentinel = SENTINEL, **kwargs):
70 super().__init__(selection=selection, string=string, **kwargs)
71 self._selection = dict(selection) if isinstance(selection, list) else None
73 def setup_nonrelated(self, model):
74 super().setup_nonrelated(model)
75 assert self.selection is not None, "Field %s without selection" % self
77 def setup_related(self, model):
78 super().setup_related(model)
79 # selection must be computed on related field
80 field = self.related_field
81 self.selection = lambda model: field._description_selection(model.env)
82 self._selection = None
84 def _get_attrs(self, model_class, name):
85 attrs = super()._get_attrs(model_class, name)
86 # arguments 'selection' and 'selection_add' are processed below
87 attrs.pop('selection_add', None)
88 # Selection fields have an optional default implementation of a group_expand function
89 if attrs.get('group_expand') is True:
90 attrs['group_expand'] = self._default_group_expand
91 return attrs
93 def _setup_attrs__(self, model_class, name):
94 super()._setup_attrs__(model_class, name)
95 if not self._base_fields__:
96 return
98 # determine selection (applying 'selection_add' extensions) as a dict
99 values = None
101 for field in self._base_fields__:
102 # We cannot use field.selection or field.selection_add here
103 # because those attributes are overridden by ``_setup_attrs__``.
104 if 'selection' in field._args__:
105 if self.related: 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true
106 _logger.warning("%s: selection attribute will be ignored as the field is related", self)
107 selection = field._args__['selection']
108 if isinstance(selection, (list, tuple)):
109 if values is not None and list(values) != [kv[0] for kv in selection]: 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true
110 _logger.warning("%s: selection=%r overrides existing selection; use selection_add instead", self, selection)
111 values = dict(selection)
112 self.ondelete = {}
113 elif callable(selection) or isinstance(selection, str): 113 ↛ 118line 113 didn't jump to line 118 because the condition on line 113 was always true
114 self.ondelete = None
115 self.selection = selection
116 values = None
117 else:
118 raise ValueError(f"{self!r}: selection={selection!r} should be a list, a callable or a method name")
120 if 'selection_add' in field._args__:
121 if self.related: 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true
122 _logger.warning("%s: selection_add attribute will be ignored as the field is related", self)
123 selection_add = field._args__['selection_add']
124 assert isinstance(selection_add, list), \
125 "%s: selection_add=%r must be a list" % (self, selection_add)
126 assert values is not None, \
127 "%s: selection_add=%r on non-list selection %r" % (self, selection_add, self.selection)
129 values_add = {kv[0]: (kv[1] if len(kv) > 1 else None) for kv in selection_add}
130 ondelete = field._args__.get('ondelete') or {}
131 new_values = [key for key in values_add if key not in values]
132 for key in new_values:
133 ondelete.setdefault(key, 'set null')
134 if self.required and new_values and 'set null' in ondelete.values(): 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true
135 raise ValueError(
136 "%r: required selection fields must define an ondelete policy that "
137 "implements the proper cleanup of the corresponding records upon "
138 "module uninstallation. Please use one or more of the following "
139 "policies: 'set default' (if the field has a default defined), 'cascade', "
140 "or a single-argument callable where the argument is the recordset "
141 "containing the specified option." % self
142 )
144 # check ondelete values
145 for key, val in ondelete.items():
146 if callable(val) or val in ('set null', 'cascade'):
147 continue
148 if val == 'set default':
149 assert self.default is not None, (
150 "%r: ondelete policy of type 'set default' is invalid for this field "
151 "as it does not define a default! Either define one in the base "
152 "field, or change the chosen ondelete policy" % self
153 )
154 elif val.startswith('set '): 154 ↛ 160line 154 didn't jump to line 160 because the condition on line 154 was always true
155 assert val[4:] in values, (
156 "%s: ondelete policy of type 'set %%' must be either 'set null', "
157 "'set default', or 'set value' where value is a valid selection value."
158 ) % self
159 else:
160 raise ValueError(
161 "%r: ondelete policy %r for selection value %r is not a valid ondelete"
162 " policy, please choose one of 'set null', 'set default', "
163 "'set [value]', 'cascade' or a callable" % (self, val, key)
164 )
166 values = {
167 key: values_add.get(key) or values[key]
168 for key in merge_sequences(values, values_add)
169 }
170 self.ondelete.update(ondelete)
172 if values is not None:
173 self.selection = list(values.items())
174 assert all(isinstance(key, str) for key in values), \
175 "Field %s with non-str value in selection" % self
177 self._selection = values
179 def _selection_modules(self, model):
180 """ Return a mapping from selection values to modules defining each value. """
181 if not isinstance(self.selection, list): 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true
182 return {}
183 value_modules = defaultdict(set)
184 for field in reversed(resolve_mro(model, self.name, type(self).__instancecheck__)):
185 module = field._module
186 if not module: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true
187 continue
188 if 'selection' in field._args__:
189 value_modules.clear()
190 if isinstance(field._args__['selection'], list): 190 ↛ 193line 190 didn't jump to line 193 because the condition on line 190 was always true
191 for value, _label in field._args__['selection']:
192 value_modules[value].add(module)
193 if 'selection_add' in field._args__:
194 for value_label in field._args__['selection_add']:
195 if len(value_label) > 1:
196 value_modules[value_label[0]].add(module)
197 return value_modules
199 def _description_selection(self, env):
200 """ return the selection list (pairs (value, label)); labels are
201 translated according to context language
202 """
203 selection = self.selection
204 if isinstance(selection, str) or callable(selection):
205 selection = determine(selection, env[self.model_name])
206 # force all values to be strings (check _get_year_selection)
207 return [(str(key), str(label)) for key, label in selection]
209 translations = dict(env['ir.model.fields'].get_field_selection(self.model_name, self.name))
210 return [(key, translations.get(key, label)) for key, label in selection]
212 def _default_group_expand(self, records, groups, domain):
213 # return a group per selection option, in definition order
214 return self.get_values(records.env)
216 def get_values(self, env):
217 """Return a list of the possible values."""
218 selection = self.selection
219 if isinstance(selection, str) or callable(selection):
220 selection = determine(selection, env[self.model_name].with_context(lang=None))
221 return [value for value, _ in selection]
223 def convert_to_column(self, value, record, values=None, validate=True):
224 if validate and self.validate:
225 value = self.convert_to_cache(value, record)
226 return super().convert_to_column(value, record, values, validate)
228 def convert_to_cache(self, value, record, validate=True):
229 if not validate or self._selection is None:
230 return value or None
231 if value in self._selection:
232 return value
233 if not value: 233 ↛ 235line 233 didn't jump to line 235 because the condition on line 233 was always true
234 return None
235 raise ValueError("Wrong value for %s: %r" % (self, value))
237 def convert_to_export(self, value, record):
238 for item in self._description_selection(record.env):
239 if item[0] == value:
240 return item[1]
241 return value or ''