Coverage for adhoc-cicd-odoo-odoo / odoo / orm / fields_properties.py: 24%

545 statements  

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

1from __future__ import annotations 

2 

3import ast 

4import contextlib 

5import copy 

6import json 

7import typing 

8import uuid 

9from collections import abc, defaultdict 

10from operator import attrgetter 

11 

12from odoo.exceptions import AccessError, UserError, MissingError 

13from odoo.tools import SQL, OrderedSet, is_list_of, html_sanitize 

14from odoo.tools.misc import frozendict, has_list_types 

15from odoo.tools.translate import _ 

16 

17from .domains import Domain 

18from .fields import Field, _logger 

19from .models import BaseModel 

20from .utils import COLLECTION_TYPES, SQL_OPERATORS, parse_field_expr, regex_alphanumeric 

21if typing.TYPE_CHECKING: 

22 from odoo.tools import Query 

23 

24NoneType = type(None) 

25 

26 

27def check_property_field_value_name(property_name): 

28 if not (0 < len(property_name) <= 512) or not regex_alphanumeric.match(property_name): 

29 raise ValueError(f"Wrong property field value name {property_name!r}.") 

30 

31 

32class Properties(Field): 

33 """ Field that contains a list of properties (aka "sub-field") based on 

34 a definition defined on a container. Properties are pseudo-fields, acting 

35 like Odoo fields but without being independently stored in database. 

36 

37 This field allows a light customization based on a container record. Used 

38 for relationships such as <project.project> / <project.task>,... New 

39 properties can be created on the fly without changing the structure of the 

40 database. 

41 

42 The "definition_record" define the field used to find the container of the 

43 current record. The container must have a :class:`~odoo.fields.PropertiesDefinition` 

44 field "definition_record_field" that contains the properties definition 

45 (type of each property, default value)... 

46 

47 Only the value of each property is stored on the child. When we read the 

48 properties field, we read the definition on the container and merge it with 

49 the value of the child. That way the web client has access to the full 

50 field definition (property type, ...). 

51 """ 

52 type = 'properties' 

53 _column_type = ('jsonb', 'jsonb') 

54 copy = False 

55 prefetch = False 

56 write_sequence = 10 # because it must be written after the definition field 

57 

58 # the field is computed editable by design (see the compute method below) 

59 store = True 

60 readonly = False 

61 precompute = True 

62 

63 definition = None 

64 definition_record = None # field on the current model that point to the definition record 

65 definition_record_field = None # field on the definition record which defined the Properties field definition 

66 

67 _description_definition_record = property(attrgetter('definition_record')) 

68 _description_definition_record_field = property(attrgetter('definition_record_field')) 

69 

70 HTML_SANITIZE_OPTIONS = { 

71 'sanitize_attributes': True, 

72 'sanitize_tags': True, 

73 'sanitize_style': False, 

74 'sanitize_form': True, 

75 'sanitize_conditional_comments': True, 

76 'strip_style': False, 

77 'strip_classes': False, 

78 } 

79 

80 ALLOWED_TYPES = ( 

81 # standard types 

82 'boolean', 'integer', 'float', 'text', 'char', 'html', 'date', 'datetime', 'monetary', 

83 # relational like types 

84 'many2one', 'many2many', 'selection', 'tags', 

85 # UI types 

86 'separator', 

87 ) 

88 

89 def _setup_attrs__(self, model_class, name): 

90 super()._setup_attrs__(model_class, name) 

91 self._setup_definition_attrs(model_class) 

92 

93 def _setup_definition_attrs(self, model_class): 

94 if self.definition: 

95 # determine definition_record and definition_record_field 

96 assert self.definition.count(".") == 1 

97 self.definition_record, self.definition_record_field = self.definition.rsplit('.', 1) 

98 

99 if not self.inherited_field: 

100 # make the field computed, and set its dependencies 

101 self._depends = (self.definition_record, ) 

102 self.compute = self._compute 

103 

104 def setup(self, model): 

105 if not self._setup_done and self.definition_record and self.definition_record_field: 

106 definition_record_field = model.env[model._fields[self.definition_record].comodel_name]._fields[self.definition_record_field] 

107 definition_record_field.properties_fields += (self,) 

108 return super().setup(model) 

109 

110 def setup_related(self, model): 

111 super().setup_related(model) 

112 if self.inherited_field and not self.definition: 

113 self.definition = self.inherited_field.definition 

114 self._setup_definition_attrs(model) 

115 

116 # Database/cache format: a value is either None, or a dict mapping property 

117 # names to their corresponding value, like 

118 # 

119 # { 

120 # '3adf37f3258cfe40': 'red', 

121 # 'aa34746a6851ee4e': 1337, 

122 # } 

123 # 

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

125 if not value: 125 ↛ 128line 125 didn't jump to line 128 because the condition on line 125 was always true

126 return None 

127 

128 value = self.convert_to_cache(value, record, validate=validate) 

129 return json.dumps(value) 

130 

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

132 # any format -> cache format {name: value} or None 

133 if not value: 133 ↛ 136line 133 didn't jump to line 136 because the condition on line 133 was always true

134 return None 

135 

136 if isinstance(value, Property): 

137 value = value._values 

138 

139 elif isinstance(value, dict): 

140 # avoid accidental side effects from shared mutable data 

141 value = copy.deepcopy(value) 

142 

143 elif isinstance(value, str): 

144 value = json.loads(value) 

145 if not isinstance(value, dict): 

146 raise ValueError(f"Wrong property value {value!r}") 

147 

148 elif isinstance(value, list): 

149 # Convert the list with all definitions into a simple dict 

150 # {name: value} to store the strict minimum on the child 

151 self._remove_display_name(value) 

152 value = self._list_to_dict(value) 

153 

154 else: 

155 raise TypeError(f"Wrong property type {type(value)!r}") 

156 

157 if validate: 

158 # Sanitize `_html` flagged properties 

159 for property_name, property_value in value.items(): 

160 if property_name.endswith('_html'): 

161 value[property_name] = html_sanitize( 

162 property_value, 

163 **self.HTML_SANITIZE_OPTIONS, 

164 ) 

165 

166 return value 

167 

168 # Record format: the value is either False, or a dict mapping property 

169 # names to their corresponding value, like 

170 # 

171 # { 

172 # '3adf37f3258cfe40': 'red', 

173 # 'aa34746a6851ee4e': 1337, 

174 # } 

175 # 

176 def convert_to_record(self, value, record): 

177 return Property(value or {}, self, record) 

178 

179 # Read format: the value is a list, where each element is a dict containing 

180 # the definition of a property, together with the property's corresponding 

181 # value, where relational field values have a display name. 

182 # 

183 # [{ 

184 # 'name': '3adf37f3258cfe40', 

185 # 'string': 'Color Code', 

186 # 'type': 'char', 

187 # 'default': 'blue', 

188 # 'value': 'red', 

189 # }, { 

190 # 'name': 'aa34746a6851ee4e', 

191 # 'string': 'Partner', 

192 # 'type': 'many2one', 

193 # 'comodel': 'test_orm.partner', 

194 # 'value': [1337, 'Bob'], 

195 # }] 

196 # 

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

198 return self.convert_to_read_multi([value], record, use_display_name)[0] 

199 

200 def convert_to_read_multi(self, values, records, use_display_name=True): 

201 if not records: 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true

202 return values 

203 assert len(values) == len(records) 

204 

205 # each value is either False or a dict 

206 result = [] 

207 for record, value in zip(records, values): 

208 value = value._values if isinstance(value, Property) else value # Property -> dict 

209 if definition := self._get_properties_definition(record): 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true

210 value = value or {} 

211 assert isinstance(value, dict), f"Wrong type {value!r}" 

212 result.append(self._dict_to_list(value, definition)) 

213 else: 

214 result.append([]) 

215 

216 res_ids_per_model = self._get_res_ids_per_model(records.env, result) 

217 

218 # value is in record format 

219 for value in result: 

220 self._parse_json_types(value, records.env, res_ids_per_model) 

221 

222 if use_display_name: 222 ↛ 226line 222 didn't jump to line 226 because the condition on line 222 was always true

223 for value in result: 

224 self._add_display_name(value, records.env) 

225 

226 return result 

227 

228 def convert_to_write(self, value, record): 

229 """If we write a list on the child, update the definition record.""" 

230 return value 

231 

232 def convert_to_export(self, value, record): 

233 """ Convert value from the record format to the export format. """ 

234 if isinstance(value, Property): 

235 value = value._values 

236 return value or '' 

237 

238 def _get_res_ids_per_model(self, env, values_list): 

239 """Read everything needed in batch for the given records. 

240 

241 To retrieve relational properties names, or to check their existence, 

242 we need to do some SQL queries. To reduce the number of queries when we read 

243 in batch, we prefetch everything needed before calling 

244 convert_to_record / convert_to_read. 

245 

246 Return a dict {model: record_ids} that contains 

247 the existing ids for each needed models. 

248 """ 

249 # ids per model we need to fetch in batch to put in cache 

250 ids_per_model = defaultdict(OrderedSet) 

251 

252 for record_values in values_list: 

253 for property_definition in record_values: 253 ↛ 254line 253 didn't jump to line 254 because the loop on line 253 never started

254 comodel = property_definition.get('comodel') 

255 type_ = property_definition.get('type') 

256 property_value = property_definition.get('value') or [] 

257 default = property_definition.get('default') or [] 

258 

259 if type_ not in ('many2one', 'many2many') or comodel not in env: 

260 continue 

261 

262 if type_ == 'many2one': 

263 default = [default] if default else [] 

264 property_value = [property_value] if isinstance(property_value, int) else [] 

265 elif not is_list_of(property_value, int): 

266 property_value = [] 

267 

268 ids_per_model[comodel].update(default) 

269 ids_per_model[comodel].update(property_value) 

270 

271 # check existence and pre-fetch in batch 

272 res_ids_per_model = {} 

273 for model, ids in ids_per_model.items(): 273 ↛ 274line 273 didn't jump to line 274 because the loop on line 273 never started

274 recs = env[model].browse(ids).exists() 

275 res_ids_per_model[model] = set(recs.ids) 

276 

277 for record in recs: 

278 # read a field to pre-fetch the recordset 

279 with contextlib.suppress(AccessError): 

280 record.display_name 

281 

282 return res_ids_per_model 

283 

284 def write(self, records, value): 

285 """Check if the properties definition has been changed. 

286 

287 To avoid extra SQL queries used to detect definition change, we add a 

288 flag in the properties list. Parent update is done only when this flag 

289 is present, delegating the check to the caller (generally web client). 

290 

291 For deletion, we need to keep the removed property definition in the 

292 list to be able to put the delete flag in it. Otherwise we have no way 

293 to know that a property has been removed. 

294 """ 

295 if isinstance(value, str): 295 ↛ 296line 295 didn't jump to line 296 because the condition on line 295 was never true

296 value = json.loads(value) 

297 

298 if isinstance(value, Property): 

299 value = value._values 

300 

301 if len(records[self.definition_record]) > 1 and value: 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true

302 raise UserError(records.env._("Updating records with different property fields definitions is not supported. Update by separate definition instead.")) 

303 

304 if isinstance(value, dict): 304 ↛ 308line 304 didn't jump to line 308 because the condition on line 304 was always true

305 # don't need to write on the container definition 

306 return super().write(records, value) 

307 

308 definition_changed = any( 

309 definition.get('definition_changed') 

310 or definition.get('definition_deleted') 

311 for definition in (value or []) 

312 ) 

313 if definition_changed: 

314 value = [ 

315 definition for definition in value 

316 if not definition.get('definition_deleted') 

317 ] 

318 for definition in value: 

319 definition.pop('definition_changed', None) 

320 

321 # update the properties definition on the container 

322 container = records[self.definition_record] 

323 if container: 

324 properties_definition = copy.deepcopy(value) 

325 for property_definition in properties_definition: 

326 property_definition.pop('value', None) 

327 container[self.definition_record_field] = properties_definition 

328 

329 _logger.info('Properties field: User #%i changed definition of %r', records.env.user.id, container) 

330 

331 return super().write(records, value) 

332 

333 def _compute(self, records): 

334 """Add the default properties value when the container is changed.""" 

335 for record in records.sudo(): 

336 record[self.name] = self._add_default_values( 

337 record.env, 

338 {self.name: record[self.name], self.definition_record: record[self.definition_record]}, 

339 ) 

340 

341 def _add_default_values(self, env, values): 

342 """Read the properties definition to add default values. 

343 

344 Default values are defined on the container in the 'default' key of 

345 the definition. 

346 

347 :param env: environment 

348 :param values: All values that will be written on the record 

349 :return: Return the default values in the "dict" format 

350 """ 

351 properties_values = values.get(self.name) or {} 

352 

353 if isinstance(properties_values, Property): 353 ↛ 354line 353 didn't jump to line 354 because the condition on line 353 was never true

354 properties_values = properties_values._values 

355 

356 if not values.get(self.definition_record): 

357 # container is not given in the value, can not find properties definition 

358 return {} 

359 

360 container_id = values[self.definition_record] 

361 if not isinstance(container_id, (int, BaseModel)): 361 ↛ 362line 361 didn't jump to line 362 because the condition on line 361 was never true

362 raise ValueError(f"Wrong container value {container_id!r}") 

363 

364 if isinstance(container_id, int): 

365 # retrieve the container record 

366 current_model = env[self.model_name] 

367 definition_record_field = current_model._fields[self.definition_record] 

368 container_model_name = definition_record_field.comodel_name 

369 container_id = env[container_model_name].sudo().browse(container_id) 

370 

371 properties_definition = container_id[self.definition_record_field] 

372 if not (properties_definition or ( 372 ↛ 381line 372 didn't jump to line 381 because the condition on line 372 was always true

373 isinstance(properties_values, list) 

374 and any(d.get('definition_changed') for d in properties_values) 

375 )): 

376 # If a parent is set without properties, we might want to change its definition 

377 # when we create the new record. But if we just set the value without changing 

378 # the definition, in that case we can just ignored the passed values 

379 return {} 

380 

381 assert isinstance(properties_values, (list, dict)) 

382 if isinstance(properties_values, list): 

383 self._remove_display_name(properties_values) 

384 properties_list_values = properties_values 

385 else: 

386 properties_list_values = self._dict_to_list(properties_values, properties_definition) 

387 

388 for properties_value in properties_list_values: 

389 if properties_value.get('value') is None: 

390 property_name = properties_value.get('name') 

391 context_key = f"default_{self.name}.{property_name}" 

392 if property_name and context_key in env.context: 

393 default = env.context[context_key] 

394 else: 

395 default = properties_value.get('default') 

396 if default: 

397 properties_value['value'] = default 

398 

399 return properties_list_values 

400 

401 def _get_properties_definition(self, record): 

402 """Return the properties definition of the given record.""" 

403 container = record[self.definition_record] 

404 if container: 404 ↛ exitline 404 didn't return from function '_get_properties_definition' because the condition on line 404 was always true

405 return container.sudo()[self.definition_record_field] 

406 

407 @classmethod 

408 def _add_display_name(cls, values_list, env, value_keys=('value', 'default')): 

409 """Add the "display_name" for each many2one / many2many properties. 

410 

411 Modify in place "values_list". 

412 

413 :param values_list: List of properties definition and values 

414 :param env: environment 

415 """ 

416 for property_definition in values_list: 416 ↛ 417line 416 didn't jump to line 417 because the loop on line 416 never started

417 property_type = property_definition.get('type') 

418 property_model = property_definition.get('comodel') 

419 if not property_model: 

420 continue 

421 

422 for value_key in value_keys: 

423 property_value = property_definition.get(value_key) 

424 

425 if property_type == 'many2one' and property_value and isinstance(property_value, int): 

426 try: 

427 display_name = env[property_model].browse(property_value).display_name 

428 property_definition[value_key] = (property_value, display_name) 

429 except AccessError: 

430 # protect from access error message, show an empty name 

431 property_definition[value_key] = (property_value, None) 

432 except MissingError: 

433 property_definition[value_key] = False 

434 

435 elif property_type == 'many2many' and property_value and is_list_of(property_value, int): 

436 property_definition[value_key] = [] 

437 records = env[property_model].browse(property_value) 

438 for record in records: 

439 try: 

440 property_definition[value_key].append((record.id, record.display_name)) 

441 except AccessError: 

442 property_definition[value_key].append((record.id, None)) 

443 except MissingError: 

444 continue 

445 

446 @classmethod 

447 def _remove_display_name(cls, values_list, value_key='value'): 

448 """Remove the display name received by the web client for the relational properties. 

449 

450 Modify in place "values_list". 

451 

452 - many2one: (35, 'Bob') -> 35 

453 - many2many: [(35, 'Bob'), (36, 'Alice')] -> [35, 36] 

454 

455 :param values_list: List of properties definition with properties value 

456 :param value_key: In which dict key we need to remove the display name 

457 """ 

458 for property_definition in values_list: 

459 if not isinstance(property_definition, dict) or not property_definition.get('name'): 

460 continue 

461 

462 property_value = property_definition.get(value_key) 

463 if not property_value: 

464 continue 

465 

466 property_type = property_definition.get('type') 

467 

468 if property_type == 'many2one' and has_list_types(property_value, [int, (str, NoneType)]): 

469 property_definition[value_key] = property_value[0] 

470 

471 elif property_type == 'many2many': 

472 if is_list_of(property_value, (list, tuple)): 

473 # [(35, 'Admin'), (36, 'Demo')] -> [35, 36] 

474 property_definition[value_key] = [ 

475 many2many_value[0] 

476 for many2many_value in property_value 

477 ] 

478 

479 @classmethod 

480 def _add_missing_names(cls, values_list): 

481 """Generate new properties name if needed. 

482 

483 Modify in place "values_list". 

484 

485 :param values_list: List of properties definition with properties value 

486 """ 

487 for definition in values_list: 

488 if definition.get('definition_changed') and not definition.get('name'): 

489 # keep only the first 64 bits 

490 definition['name'] = str(uuid.uuid4()).replace('-', '')[:16] 

491 

492 @classmethod 

493 def _parse_json_types(cls, values_list, env, res_ids_per_model): 

494 """Parse the value stored in the JSON. 

495 

496 Check for records existence, if we removed a selection option, ... 

497 Modify in place "values_list". 

498 

499 :param values_list: List of properties definition and values 

500 :param env: environment 

501 """ 

502 for property_definition in values_list: 502 ↛ 503line 502 didn't jump to line 503 because the loop on line 502 never started

503 property_value = property_definition.get('value') 

504 property_type = property_definition.get('type') 

505 res_model = property_definition.get('comodel') 

506 

507 if property_type not in cls.ALLOWED_TYPES: 

508 raise ValueError(f'Wrong property type {property_type!r}') 

509 

510 if property_value is None: 

511 continue 

512 

513 if property_type == 'boolean': 

514 # E.G. convert zero to False 

515 property_value = bool(property_value) 

516 

517 elif property_type in ('char', 'text') and not isinstance(property_value, str): 

518 property_value = False 

519 

520 elif property_value and property_type == 'selection': 

521 # check if the selection option still exists 

522 options = property_definition.get('selection') or [] 

523 options = {option[0] for option in options if option or ()} # always length 2 

524 if property_value not in options: 

525 # maybe the option has been removed on the container 

526 property_value = False 

527 

528 elif property_value and property_type == 'tags': 

529 # remove all tags that are not defined on the container 

530 all_tags = {tag[0] for tag in property_definition.get('tags') or ()} 

531 property_value = [tag for tag in property_value if tag in all_tags] 

532 

533 elif property_type == 'many2one': 

534 if not isinstance(property_value, int) \ 

535 or res_model not in env \ 

536 or property_value not in res_ids_per_model[res_model]: 

537 property_value = False 

538 

539 elif property_type == 'many2many': 

540 if not is_list_of(property_value, int): 

541 property_value = [] 

542 

543 elif len(property_value) != len(set(property_value)): 

544 # remove duplicated value and preserve order 

545 property_value = list(dict.fromkeys(property_value)) 

546 

547 property_value = [ 

548 id_ for id_ in property_value 

549 if id_ in res_ids_per_model[res_model] 

550 ] if res_model in env else [] 

551 

552 elif property_type == 'html': 

553 # field name should end with `_html` to be legit and sanitized, 

554 # otherwise do not trust the value and force False 

555 property_value = property_definition['name'].endswith('_html') and property_value 

556 

557 property_definition['value'] = property_value 

558 

559 @classmethod 

560 def _list_to_dict(cls, values_list): 

561 """Convert a list of properties with definition into a dict {name: value}. 

562 

563 To not repeat data in database, we only store the value of each property on 

564 the child. The properties definition is stored on the container. 

565 

566 E.G. 

567 Input list: 

568 [{ 

569 'name': '3adf37f3258cfe40', 

570 'string': 'Color Code', 

571 'type': 'char', 

572 'default': 'blue', 

573 'value': 'red', 

574 }, { 

575 'name': 'aa34746a6851ee4e', 

576 'string': 'Partner', 

577 'type': 'many2one', 

578 'comodel': 'test_orm.partner', 

579 'value': [1337, 'Bob'], 

580 }] 

581 

582 Output dict: 

583 { 

584 '3adf37f3258cfe40': 'red', 

585 'aa34746a6851ee4e': 1337, 

586 } 

587 

588 :param values_list: List of properties definition and value 

589 :return: Generate a dict {name: value} from this definitions / values list 

590 """ 

591 if not is_list_of(values_list, dict): 

592 raise ValueError(f'Wrong properties value {values_list!r}') 

593 

594 cls._add_missing_names(values_list) 

595 

596 dict_value = {} 

597 for property_definition in values_list: 

598 property_value = property_definition.get('value') 

599 property_type = property_definition.get('type') 

600 property_model = property_definition.get('comodel') 

601 if property_value is None: 

602 # Do not store None key 

603 continue 

604 

605 if property_type not in ('integer', 'float') or property_value != 0: 

606 property_value = property_value or False 

607 if property_type in ('many2one', 'many2many') and property_model and property_value: 

608 # check that value are correct before storing them in database 

609 if property_type == 'many2many' and property_value and not is_list_of(property_value, int): 

610 raise ValueError(f"Wrong many2many value {property_value!r}") 

611 

612 if property_type == 'many2one' and not isinstance(property_value, int): 

613 raise ValueError(f"Wrong many2one value {property_value!r}") 

614 

615 dict_value[property_definition['name']] = property_value 

616 

617 return dict_value 

618 

619 @classmethod 

620 def _dict_to_list(cls, values_dict, properties_definition): 

621 """Convert a dict of {property: value} into a list of property definition with values. 

622 

623 :param values_dict: JSON value coming from the child table 

624 :param properties_definition: Properties definition coming from the container table 

625 :return: Merge both value into a list of properties with value 

626 Ignore every values in the child that is not defined on the container. 

627 """ 

628 if not is_list_of(properties_definition, dict): 

629 raise ValueError(f'Wrong properties value {properties_definition!r}') 

630 

631 values_list = copy.deepcopy(properties_definition) 

632 for property_definition in values_list: 

633 if property_definition['name'] in values_dict: 

634 property_definition['value'] = values_dict[property_definition['name']] 

635 else: 

636 property_definition.pop('value', None) 

637 return values_list 

638 

639 def expression_getter(self, field_expr): 

640 _fname, property_name = parse_field_expr(field_expr) 

641 if not property_name: 

642 raise ValueError(f"Missing property name for {self}") 

643 

644 def get_property(record): 

645 property_value = self.__get__(record.with_context(property_selection_get_key=True)) 

646 value = property_value.get(property_name) 

647 if value: 

648 return value 

649 # find definition to check the type 

650 for definition in self._get_properties_definition(record) or (): 

651 if definition.get('name') == property_name: 

652 break 

653 else: 

654 # definition not found 

655 return value or False 

656 

657 if not value and definition['type'] in ('many2one', 'many2many'): 

658 return record.env.get(definition.get('comodel')) 

659 return value 

660 

661 return get_property 

662 

663 def filter_function(self, records, field_expr, operator, value): 

664 getter = self.expression_getter(field_expr) 

665 domain = None 

666 if operator == 'any' or isinstance(value, Domain): 

667 domain = Domain(value).optimize(records) 

668 elif operator == 'in' and isinstance(value, COLLECTION_TYPES) and isinstance(getter(records.browse()), BaseModel): 

669 domain = Domain('id', 'in', value).optimize(records) 

670 if domain is not None: 

671 return lambda rec: getter(rec).filtered_domain(domain) 

672 return super().filter_function(records, field_expr, operator, value) 

673 

674 def property_to_sql(self, field_sql: SQL, property_name: str, model: BaseModel, alias: str, query: Query) -> SQL: 

675 check_property_field_value_name(property_name) 

676 return SQL("(%s -> %s)", field_sql, property_name) 

677 

678 def condition_to_sql(self, field_expr: str, operator: str, value, model: BaseModel, alias: str, query: Query) -> SQL: 

679 fname, property_name = parse_field_expr(field_expr) 

680 if not property_name: 

681 raise ValueError(f"Missing property name for {self}") 

682 raw_sql_field = model._field_to_sql(alias, fname, query) 

683 sql_left = model._field_to_sql(alias, field_expr, query) 

684 

685 if operator in ('in', 'not in'): 

686 assert isinstance(value, COLLECTION_TYPES) 

687 if len(value) == 1 and True in value: 

688 # inverse the condition 

689 check_null_op_false = "!=" if operator == 'in' else "=" 

690 value = [] 

691 operator = 'in' if operator == 'not in' else 'not in' 

692 elif False in value: 

693 check_null_op_false = "=" if operator == 'in' else "!=" 

694 value = [v for v in value if v] 

695 else: 

696 value = list(value) 

697 check_null_op_false = None 

698 

699 sqls = [] 

700 if check_null_op_false: 

701 sqls.append(SQL( 

702 "%s%s'%s'", 

703 sql_left, 

704 SQL_OPERATORS[check_null_op_false], 

705 False, 

706 )) 

707 if check_null_op_false == '=': 

708 # check null value too 

709 sqls.extend(( 

710 SQL("%s IS NULL", raw_sql_field), 

711 SQL("NOT (%s ? %s)", raw_sql_field, property_name), 

712 )) 

713 # left can be an array or a single value! 

714 # Even if we use the '=' operator, we must check the list subset. 

715 # There is an unsupported edge-case where left is a list and we 

716 # have multiple values. 

717 if len(value) == 1: 

718 # check single value equality 

719 sql_operator = SQL_OPERATORS['=' if operator == 'in' else '!='] 

720 sql_right = SQL("%s", json.dumps(value[0])) 

721 sqls.append(SQL("%s%s%s", sql_left, sql_operator, sql_right)) 

722 if value: 

723 sql_not = SQL('NOT ') if operator == 'not in' else SQL() 

724 # hackish operator to search values 

725 if len(value) > 1: 

726 # left <@ value_list -- single left value in value_list 

727 # (here we suppose left is a single value) 

728 sql_operator = SQL(" <@ ") 

729 else: 

730 # left @> value -- value_list in left 

731 sql_operator = SQL(" @> ") 

732 sql_right = SQL("%s", json.dumps(value)) 

733 sqls.append(SQL( 

734 "%s%s%s%s", 

735 sql_not, sql_left, sql_operator, sql_right, 

736 )) 

737 assert sqls, "No SQL generated for property" 

738 if len(sqls) == 1: 

739 return sqls[0] 

740 combine_sql = SQL(" OR ") if operator == 'in' else SQL(" AND ") 

741 return SQL("(%s)", combine_sql.join(sqls)) 

742 

743 unaccent = lambda x: x # noqa: E731 

744 if operator.endswith('like'): 

745 if operator.endswith('ilike'): 

746 unaccent = model.env.registry.unaccent 

747 if '=' in operator: 

748 value = str(value) 

749 else: 

750 value = f'%{value}%' 

751 

752 try: 

753 sql_operator = SQL_OPERATORS[operator] 

754 except KeyError: 

755 raise ValueError(f"Invalid operator {operator} for Properties") 

756 

757 if isinstance(value, str): 

758 sql_left = SQL("(%s ->> %s)", raw_sql_field, property_name) # JSONified value 

759 sql_right = SQL("%s", value) 

760 sql = SQL( 

761 "%s%s%s", 

762 unaccent(sql_left), sql_operator, unaccent(sql_right), 

763 ) 

764 if operator in Domain.NEGATIVE_OPERATORS: 

765 sql = SQL("(%s OR %s IS NULL)", sql, sql_left) 

766 return sql 

767 

768 sql_right = SQL("%s", json.dumps(value)) 

769 return SQL( 

770 "%s%s%s", 

771 unaccent(sql_left), sql_operator, unaccent(sql_right), 

772 ) 

773 

774 

775class Property(abc.Mapping): 

776 """Represent a collection of properties of a record. 

777 

778 An object that implements the value of a :class:`Properties` field in the "record" 

779 format, i.e., the result of evaluating an expression like ``record.property_field``. 

780 The value behaves as a ``dict``, and individual properties are returned in their 

781 expected type, according to ORM conventions. For instance, the value of a many2one 

782 property is returned as a recordset:: 

783 

784 # attributes is a properties field, and 'partner_id' is a many2one property; 

785 # partner is thus a recordset 

786 partner = record.attributes['partner_id'] 

787 partner.name 

788 

789 When the accessed key does not exist, i.e., there is no corresponding property 

790 definition for that record, the access raises a :class:`KeyError`. 

791 """ 

792 

793 def __init__(self, values, field, record): 

794 self._values = values 

795 self.record = record 

796 self.field = field 

797 

798 def __iter__(self): 

799 for key in self._values: 

800 with contextlib.suppress(KeyError): 

801 self[key] 

802 yield key 

803 

804 def __len__(self): 

805 return len(self._values) 

806 

807 def __eq__(self, other): 

808 return self._values == (other._values if isinstance(other, Property) else other) 

809 

810 def __getitem__(self, property_name): 

811 """Will make the verification.""" 

812 if not self.record: 

813 return False 

814 

815 values = self.field.convert_to_read( 

816 self._values, 

817 self.record, 

818 use_display_name=False, 

819 ) 

820 prop = next((p for p in values if p['name'] == property_name), False) 

821 if not prop: 

822 raise KeyError(property_name) 

823 

824 if prop.get('type') == 'many2one' and prop.get('comodel'): 

825 return self.record.env[prop.get('comodel')].browse(prop.get('value')) 

826 

827 if prop.get('type') == 'many2many' and prop.get('comodel'): 

828 return self.record.env[prop.get('comodel')].browse(prop.get('value')) 

829 

830 if prop.get('type') == 'selection' and prop.get('value'): 

831 if self.record.env.context.get('property_selection_get_key'): 

832 return next((sel[0] for sel in prop.get('selection') if sel[0] == prop['value']), False) 

833 return next((sel[1] for sel in prop.get('selection') if sel[0] == prop['value']), False) 

834 

835 if prop.get('type') == 'tags' and prop.get('value'): 

836 return ', '.join(tag[1] for tag in prop.get('tags') if tag[0] in prop['value']) 

837 

838 return prop.get('value') or False 

839 

840 def __hash__(self): 

841 return hash(frozendict(self._values)) 

842 

843 

844class PropertiesDefinition(Field): 

845 """ Field used to define the properties definition (see :class:`~odoo.fields.Properties` 

846 field). This field is used on the container record to define the structure 

847 of expected properties on subrecords. It is used to check the properties 

848 definition. """ 

849 type = 'properties_definition' 

850 _column_type = ('jsonb', 'jsonb') 

851 copy = True # containers may act like templates, keep definitions to ease usage 

852 readonly = False 

853 prefetch = True 

854 properties_fields = () # List of Properties fields using that definition 

855 

856 REQUIRED_KEYS = ('name', 'type') 

857 ALLOWED_KEYS = ( 

858 'name', 'string', 'type', 'comodel', 'default', 'suffix', 

859 'selection', 'tags', 'domain', 'view_in_cards', 'fold_by_default', 

860 'currency_field' 

861 ) 

862 # those keys will be removed if the types does not match 

863 PROPERTY_PARAMETERS_MAP = { 

864 'comodel': {'many2one', 'many2many'}, 

865 'currency_field': {'monetary'}, 

866 'domain': {'many2one', 'many2many'}, 

867 'selection': {'selection'}, 

868 'tags': {'tags'}, 

869 } 

870 

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

872 """Convert the value before inserting it in database. 

873 

874 This method accepts a list properties definition. 

875 

876 The relational properties (many2one / many2many) default value 

877 might contain the display_name of those records (and will be removed). 

878 

879 [{ 

880 'name': '3adf37f3258cfe40', 

881 'string': 'Color Code', 

882 'type': 'char', 

883 'default': 'blue', 

884 'value': 'red', 

885 }, { 

886 'name': 'aa34746a6851ee4e', 

887 'string': 'Partner', 

888 'type': 'many2one', 

889 'comodel': 'test_orm.partner', 

890 'default': [1337, 'Bob'], 

891 }] 

892 """ 

893 if not value: 

894 return None 

895 

896 if isinstance(value, str): 

897 value = json.loads(value) 

898 

899 if not isinstance(value, list): 

900 raise TypeError(f'Wrong properties definition type {type(value)!r}') 

901 

902 if validate: 

903 Properties._remove_display_name(value, value_key='default') 

904 

905 self._validate_properties_definition(value, record.env) 

906 

907 return json.dumps(record._convert_to_cache_properties_definition(value)) 

908 

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

910 # any format -> cache format (list of dicts or None) 

911 if not value: 

912 return None 

913 

914 if isinstance(value, list): 

915 # avoid accidental side effects from shared mutable data, and make 

916 # the value strict with respect to JSON (tuple -> list, etc) 

917 value = json.dumps(value) 

918 

919 if isinstance(value, str): 

920 value = json.loads(value) 

921 

922 if not isinstance(value, list): 

923 raise TypeError(f'Wrong properties definition type {type(value)!r}') 

924 

925 if validate: 

926 Properties._remove_display_name(value, value_key='default') 

927 

928 self._validate_properties_definition(value, record.env) 

929 

930 return record._convert_to_column_properties_definition(value) 

931 

932 def convert_to_record(self, value, record): 

933 # cache format -> record format (list of dicts) 

934 if not value: 934 ↛ 939line 934 didn't jump to line 939 because the condition on line 934 was always true

935 return [] 

936 

937 # return a copy of the definition in cache where all property 

938 # definitions have been cleaned up 

939 result = [] 

940 

941 for property_definition in value: 

942 if not all(property_definition.get(key) for key in self.REQUIRED_KEYS): 

943 # some required keys are missing, ignore this property definition 

944 continue 

945 

946 # don't modify the value in cache 

947 property_definition = copy.deepcopy(property_definition) 

948 

949 type_ = property_definition.get('type') 

950 

951 if type_ in ('many2one', 'many2many'): 

952 # check if the model still exists in the environment, the module of the 

953 # model might have been uninstalled so the model might not exist anymore 

954 property_model = property_definition.get('comodel') 

955 if property_model not in record.env: 

956 property_definition['comodel'] = False 

957 property_definition.pop('domain', None) 

958 elif property_domain := property_definition.get('domain'): 

959 # some fields in the domain might have been removed 

960 # (e.g. if the module has been uninstalled) 

961 # check if the domain is still valid 

962 try: 

963 dom = Domain(ast.literal_eval(property_domain)) 

964 model = record.env[property_model] 

965 dom.validate(model) 

966 except ValueError: 

967 del property_definition['domain'] 

968 

969 elif type_ in ('selection', 'tags'): 

970 # always set at least an empty array if there's no option 

971 property_definition[type_] = property_definition.get(type_) or [] 

972 

973 result.append(property_definition) 

974 

975 return result 

976 

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

978 # record format -> read format (list of dicts with display names) 

979 if not value: 

980 return value 

981 

982 if use_display_name: 

983 Properties._add_display_name(value, record.env, value_keys=('default',)) 

984 

985 return value 

986 

987 def convert_to_write(self, value, record): 

988 return value 

989 

990 def _validate_properties_definition(self, properties_definition, env): 

991 """Raise an error if the property definition is not valid.""" 

992 allowed_keys = self.ALLOWED_KEYS + env["base"]._additional_allowed_keys_properties_definition() 

993 

994 env["base"]._validate_properties_definition(properties_definition, self) 

995 

996 properties_names = set() 

997 

998 for property_definition in properties_definition: 

999 for property_parameter, allowed_types in self.PROPERTY_PARAMETERS_MAP.items(): 

1000 if property_definition.get('type') not in allowed_types and property_parameter in property_definition: 

1001 raise ValueError(f'Invalid property parameter {property_parameter!r}') 

1002 

1003 property_definition_keys = set(property_definition.keys()) 

1004 

1005 invalid_keys = property_definition_keys - set(allowed_keys) 

1006 if invalid_keys: 

1007 raise ValueError( 

1008 'Some key are not allowed for a properties definition [%s].' % 

1009 ', '.join(invalid_keys), 

1010 ) 

1011 

1012 check_property_field_value_name(property_definition['name']) 

1013 

1014 required_keys = set(self.REQUIRED_KEYS) - property_definition_keys 

1015 if required_keys: 

1016 raise ValueError( 

1017 'Some key are missing for a properties definition [%s].' % 

1018 ', '.join(required_keys), 

1019 ) 

1020 

1021 property_type = property_definition.get('type') 

1022 property_name = property_definition.get('name') 

1023 if not property_name or property_name in properties_names: 

1024 raise ValueError(f'The property name {property_name!r} is not set or duplicated.') 

1025 properties_names.add(property_name) 

1026 

1027 if property_type == 'html' and not property_name.endswith('_html'): 

1028 raise ValueError("HTML property name should end with `_html`.") 

1029 

1030 if property_type != 'html' and property_name.endswith('_html'): 

1031 raise ValueError("Only HTML properties can have the `_html` suffix.") 

1032 

1033 if property_type and property_type not in Properties.ALLOWED_TYPES: 

1034 raise ValueError(f'Wrong property type {property_type!r}.') 

1035 

1036 if property_type == 'html' and (default := property_definition.get('default')): 

1037 property_definition['default'] = html_sanitize(default, **Properties.HTML_SANITIZE_OPTIONS) 

1038 

1039 model = property_definition.get('comodel') 

1040 if model and (model not in env or env[model].is_transient() or env[model]._abstract): 

1041 raise ValueError(f'Invalid model name {model!r}') 

1042 

1043 property_selection = property_definition.get('selection') 

1044 if property_selection: 

1045 if (not is_list_of(property_selection, (list, tuple)) 

1046 or not all(len(selection) == 2 for selection in property_selection)): 

1047 raise ValueError(f'Wrong options {property_selection!r}.') 

1048 

1049 all_options = [option[0] for option in property_selection] 

1050 if len(all_options) != len(set(all_options)): 

1051 duplicated = set(filter(lambda x: all_options.count(x) > 1, all_options)) 

1052 raise ValueError(f'Some options are duplicated: {", ".join(duplicated)}.') 

1053 

1054 property_tags = property_definition.get('tags') 

1055 if property_tags: 

1056 if (not is_list_of(property_tags, (list, tuple)) 

1057 or not all(len(tag) == 3 and isinstance(tag[2], int) for tag in property_tags)): 

1058 raise ValueError(f'Wrong tags definition {property_tags!r}.') 

1059 

1060 all_tags = [tag[0] for tag in property_tags] 

1061 if len(all_tags) != len(set(all_tags)): 

1062 duplicated = set(filter(lambda x: all_tags.count(x) > 1, all_tags)) 

1063 raise ValueError(f'Some tags are duplicated: {", ".join(duplicated)}.')