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:15 +0000

1from __future__ import annotations 

2 

3import typing 

4from collections import defaultdict 

5 

6from odoo.tools.misc import ReadonlyDict, SENTINEL, Sentinel, merge_sequences 

7from odoo.tools.sql import pg_varchar 

8 

9from .fields import Field, _logger, determine, resolve_mro 

10 

11if typing.TYPE_CHECKING: 

12 from collections.abc import Callable 

13 

14 from .types import BaseModel 

15 

16 SelectValue = tuple[str, str] # (value, string) 

17 OnDeletePolicy = str | Callable[[BaseModel], None] 

18 

19 

20class Selection(Field[str | typing.Literal[False]]): 

21 """ Encapsulates an exclusive choice between different values. 

22 

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 

27 

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:: 

33 

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)) 

38 

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. 

42 

43 This fallback action will be applied to all records whose 

44 selection_add option maps to it. 

45 

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 

58 

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()) 

64 

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) 

68 

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 

72 

73 def setup_nonrelated(self, model): 

74 super().setup_nonrelated(model) 

75 assert self.selection is not None, "Field %s without selection" % self 

76 

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 

83 

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 

92 

93 def _setup_attrs__(self, model_class, name): 

94 super()._setup_attrs__(model_class, name) 

95 if not self._base_fields__: 

96 return 

97 

98 # determine selection (applying 'selection_add' extensions) as a dict 

99 values = None 

100 

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") 

119 

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) 

128 

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 ) 

143 

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 ) 

165 

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) 

171 

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 

176 

177 self._selection = values 

178 

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 

198 

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] 

208 

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] 

211 

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) 

215 

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] 

222 

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) 

227 

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)) 

236 

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 ''