Coverage for adhoc-cicd-odoo-odoo / odoo / tools / convert.py: 77%

510 statements  

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

1# -*- coding: utf-8 -*- 

2# Part of Odoo. See LICENSE file for full copyright and licensing details. 

3 

4__all__ = [ 

5 'convert_file', 'convert_sql_import', 

6 'convert_csv_import', 'convert_xml_import' 

7] 

8import base64 

9import csv 

10import io 

11import logging 

12import os.path 

13import pprint 

14import re 

15import subprocess 

16import warnings 

17from datetime import datetime, timedelta 

18from typing import Literal, Optional 

19 

20from dateutil.relativedelta import relativedelta 

21from lxml import etree, builder 

22try: 

23 import jingtrang 

24except ImportError: 

25 jingtrang = None 

26 

27from .config import config 

28from .misc import file_open, file_path, SKIPPED_ELEMENT_TYPES 

29from odoo.exceptions import ValidationError 

30 

31from .safe_eval import safe_eval, pytz, time 

32 

33_logger = logging.getLogger(__name__) 

34 

35ConvertMode = Literal['init', 'update'] 

36IdRef = dict[str, int | Literal[False]] 

37 

38class ParseError(Exception): 

39 ... 

40 

41 

42def _get_eval_context(self, env, model_str): 

43 from odoo import fields, release # noqa: PLC0415 

44 context = dict(Command=fields.Command, 

45 time=time, 

46 DateTime=datetime, 

47 datetime=datetime, 

48 timedelta=timedelta, 

49 relativedelta=relativedelta, 

50 version=release.major_version, 

51 ref=self.id_get, 

52 pytz=pytz) 

53 if model_str: 

54 context['obj'] = env[model_str].browse 

55 return context 

56 

57def _fix_multiple_roots(node): 

58 """ 

59 Surround the children of the ``node`` element of an XML field with a 

60 single root "data" element, to prevent having a document with multiple 

61 roots once parsed separately. 

62 

63 XML nodes should have one root only, but we'd like to support 

64 direct multiple roots in our partial documents (like inherited view architectures). 

65 As a convention we'll surround multiple root with a container "data" element, to be 

66 ignored later when parsing. 

67 """ 

68 real_nodes = [x for x in node if not isinstance(x, SKIPPED_ELEMENT_TYPES)] 

69 if len(real_nodes) > 1: 

70 data_node = etree.Element("data") 

71 for child in node: 

72 data_node.append(child) 

73 node.append(data_node) 

74 

75def _eval_xml(self, node, env): 

76 if node.tag in ('field','value'): 

77 t = node.get('type','char') 

78 f_model = node.get('model') 

79 if f_search := node.get('search'): 

80 f_use = node.get("use",'id') 

81 f_name = node.get("name") 

82 context = _get_eval_context(self, env, f_model) 

83 q = safe_eval(f_search, context) 

84 ids = env[f_model].search(q).ids 

85 if f_use != 'id': 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true

86 ids = [x[f_use] for x in env[f_model].browse(ids).read([f_use])] 

87 _fields = env[f_model]._fields 

88 if (f_name in _fields) and _fields[f_name].type == 'many2many': 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true

89 return ids 

90 f_val = False 

91 if len(ids): 

92 f_val = ids[0] 

93 if isinstance(f_val, tuple): 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true

94 f_val = f_val[0] 

95 return f_val 

96 if a_eval := node.get('eval'): 

97 context = _get_eval_context(self, env, f_model) 

98 try: 

99 return safe_eval(a_eval, context) 

100 except Exception: 

101 logging.getLogger('odoo.tools.convert.init').error( 

102 'Could not eval(%s) for %s in %s', a_eval, node.get('name'), env.context) 

103 raise 

104 def _process(s): 

105 matches = re.finditer(br'[^%]%\((.*?)\)[ds]'.decode('utf-8'), s) 

106 done = set() 

107 for m in matches: 

108 found = m.group()[1:] 

109 if found in done: 

110 continue 

111 done.add(found) 

112 rec_id = m[1] 

113 xid = self.make_xml_id(rec_id) 

114 if (record_id := self.idref.get(xid)) is None: 

115 record_id = self.idref[xid] = self.id_get(xid) 

116 # So funny story: in Python 3, bytes(n: int) returns a 

117 # bytestring of n nuls. In Python 2 it obviously returns the 

118 # stringified number, which is what we're expecting here 

119 s = s.replace(found, str(record_id)) 

120 s = s.replace('%%', '%') # Quite weird but it's for (somewhat) backward compatibility sake 

121 return s 

122 

123 if t == 'xml': 

124 _fix_multiple_roots(node) 

125 return '<?xml version="1.0"?>\n'\ 

126 +_process("".join(etree.tostring(n, encoding='unicode') for n in node)) 

127 if t == 'html': 

128 return _process("".join(etree.tostring(n, method='html', encoding='unicode') for n in node)) 

129 

130 if node.get('file'): 

131 if t == 'base64': 131 ↛ 135line 131 didn't jump to line 135 because the condition on line 131 was always true

132 with file_open(node.get('file'), 'rb', env=env) as f: 

133 return base64.b64encode(f.read()) 

134 

135 with file_open(node.get('file'), env=env) as f: 

136 data = f.read() 

137 else: 

138 data = node.text or '' 

139 

140 match t: 

141 case 'file': 141 ↛ 142line 141 didn't jump to line 142 because the pattern on line 141 never matched

142 path = data.strip() 

143 try: 

144 file_path(os.path.join(self.module, path)) 

145 except FileNotFoundError: 

146 raise FileNotFoundError( 

147 f"No such file or directory: {path!r} in {self.module}" 

148 ) from None 

149 return '%s,%s' % (self.module, path) 

150 case 'char': 

151 return data 

152 case 'int': 152 ↛ 157line 152 didn't jump to line 157 because the pattern on line 152 always matched

153 d = data.strip() 

154 if d == 'None': 154 ↛ 155line 154 didn't jump to line 155 because the condition on line 154 was never true

155 return None 

156 return int(d) 

157 case 'float': 

158 return float(data.strip()) 

159 case 'list': 

160 return [_eval_xml(self, n, env) for n in node.iterchildren('value')] 

161 case 'tuple': 

162 return tuple(_eval_xml(self, n, env) for n in node.iterchildren('value')) 

163 case 'base64': 

164 raise ValueError("base64 type is only compatible with file data") 

165 case t: 

166 raise ValueError(f"Unknown type {t!r}") 

167 

168 elif node.tag == "function": 168 ↛ 201line 168 didn't jump to line 201 because the condition on line 168 was always true

169 from odoo.models import BaseModel # noqa: PLC0415 

170 model_str = node.get('model') 

171 model = env[model_str] 

172 method_name = node.get('name') 

173 # determine arguments 

174 args = [] 

175 kwargs = {} 

176 

177 if a_eval := node.get('eval'): 

178 context = _get_eval_context(self, env, model_str) 

179 args = list(safe_eval(a_eval, context)) 

180 for child in node: 

181 if child.tag == 'value' and child.get('name'): 

182 kwargs[child.get('name')] = _eval_xml(self, child, env) 

183 else: 

184 args.append(_eval_xml(self, child, env)) 

185 # merge current context with context in kwargs 

186 if 'context' in kwargs: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true

187 model = model.with_context(**kwargs.pop('context')) 

188 method = getattr(model, method_name) 

189 is_model_method = getattr(method, '_api_model', False) 

190 if is_model_method: 

191 pass # already bound to an empty recordset 

192 else: 

193 record_ids, *args = args 

194 model = model.browse(record_ids) 

195 method = getattr(model, method_name) 

196 # invoke method 

197 result = method(*args, **kwargs) 

198 if isinstance(result, BaseModel): 

199 result = result.ids 

200 return result 

201 elif node.tag == "test": 

202 return node.text 

203 

204 

205def str2bool(value): 

206 return value.lower() not in ('0', 'false', 'off') 

207 

208def nodeattr2bool(node, attr, default=False): 

209 if not node.get(attr): 

210 return default 

211 val = node.get(attr).strip() 

212 if not val: 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true

213 return default 

214 return str2bool(val) 

215 

216class xml_import(object): 

217 def get_env(self, node, eval_context=None): 

218 uid = node.get('uid') 

219 context = node.get('context') 

220 if uid or context: 

221 return self.env( 

222 user=uid and self.id_get(uid), 

223 context=context and { 

224 **self.env.context, 

225 **safe_eval(context, { 

226 'ref': self.id_get, 

227 **(eval_context or {}) 

228 }), 

229 } 

230 ) 

231 return self.env 

232 

233 def make_xml_id(self, xml_id): 

234 if not xml_id or '.' in xml_id: 

235 return xml_id 

236 return "%s.%s" % (self.module, xml_id) 

237 

238 def _test_xml_id(self, xml_id): 

239 if '.' in xml_id: 

240 module, id = xml_id.split('.', 1) 

241 assert '.' not in id, """The ID reference "%s" must contain 

242maximum one dot. They are used to refer to other modules ID, in the 

243form: module.record_id""" % (xml_id,) 

244 if module != self.module: 

245 modcnt = self.env['ir.module.module'].search_count([('name', '=', module), ('state', '=', 'installed')]) 

246 assert modcnt == 1, """The ID "%s" refers to an uninstalled module""" % (xml_id,) 

247 

248 def _tag_delete(self, rec): 

249 d_model = rec.get("model") 

250 records = self.env[d_model] 

251 

252 if d_search := rec.get("search"): 

253 context = _get_eval_context(self, self.env, d_model) 

254 try: 

255 records = records.search(safe_eval(d_search, context)) 

256 except ValueError: 

257 _logger.warning('Skipping deletion for failed search `%r`', d_search, exc_info=True) 

258 

259 if d_id := rec.get("id"): 

260 try: 

261 records += records.browse(self.id_get(d_id)) 

262 except ValueError: 

263 # d_id cannot be found. doesn't matter in this case 

264 _logger.warning('Skipping deletion for missing XML ID `%r`', d_id, exc_info=True) 

265 

266 if records: 

267 records.unlink() 

268 

269 def _tag_function(self, rec): 

270 if self.noupdate and self.mode != 'init': 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true

271 return 

272 env = self.get_env(rec) 

273 _eval_xml(self, rec, env) 

274 

275 def _tag_menuitem(self, rec, parent=None): 

276 rec_id = rec.attrib["id"] 

277 self._test_xml_id(rec_id) 

278 

279 # The parent attribute was specified, if non-empty determine its ID, otherwise 

280 # explicitly make a top-level menu 

281 values = { 

282 'parent_id': False, 

283 'active': nodeattr2bool(rec, 'active', default=True), 

284 } 

285 

286 if rec.get('sequence'): 

287 values['sequence'] = int(rec.get('sequence')) 

288 

289 if parent is not None: 

290 values['parent_id'] = parent 

291 elif rec.get('parent'): 

292 values['parent_id'] = self.id_get(rec.attrib['parent']) 

293 elif rec.get('web_icon'): 293 ↛ 297line 293 didn't jump to line 297 because the condition on line 293 was always true

294 values['web_icon'] = rec.attrib['web_icon'] 

295 

296 

297 if rec.get('name'): 

298 values['name'] = rec.attrib['name'] 

299 

300 if rec.get('action'): 

301 a_action = rec.attrib['action'] 

302 

303 if '.' not in a_action: 

304 a_action = '%s.%s' % (self.module, a_action) 

305 act = self.env.ref(a_action).sudo() 

306 values['action'] = "%s,%d" % (act.type, act.id) 

307 

308 if not values.get('name') and act.type.endswith(('act_window', 'wizard', 'url', 'client', 'server')) and act.name: 

309 values['name'] = act.name 

310 

311 if not values.get('name'): 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true

312 values['name'] = rec_id or '?' 

313 

314 from odoo.fields import Command # noqa: PLC0415 

315 groups = [] 

316 for group in rec.get('groups', '').split(','): 

317 if group.startswith('-'): 317 ↛ 318line 317 didn't jump to line 318 because the condition on line 317 was never true

318 group_id = self.id_get(group[1:]) 

319 groups.append(Command.unlink(group_id)) 

320 elif group: 

321 group_id = self.id_get(group) 

322 groups.append(Command.link(group_id)) 

323 if groups: 

324 values['group_ids'] = groups 

325 

326 

327 data = { 

328 'xml_id': self.make_xml_id(rec_id), 

329 'values': values, 

330 'noupdate': self.noupdate, 

331 } 

332 menu = self.env['ir.ui.menu']._load_records([data], self.mode == 'update') 

333 for child in rec.iterchildren('menuitem'): 

334 self._tag_menuitem(child, parent=menu.id) 

335 

336 def _tag_record(self, rec, extra_vals=None): 

337 rec_model = rec.get("model") 

338 env = self.get_env(rec) 

339 rec_id = rec.get("id", '') 

340 

341 model = env[rec_model] 

342 

343 if self.xml_filename and rec_id: 343 ↛ 351line 343 didn't jump to line 351 because the condition on line 343 was always true

344 model = model.with_context( 

345 install_mode=True, 

346 install_module=self.module, 

347 install_filename=self.xml_filename, 

348 install_xmlid=rec_id, 

349 ) 

350 

351 self._test_xml_id(rec_id) 

352 xid = self.make_xml_id(rec_id) 

353 

354 # in update mode, the record won't be updated if the data node explicitly 

355 # opt-out using @noupdate="1". A second check will be performed in 

356 # model._load_records() using the record's ir.model.data `noupdate` field. 

357 if self.noupdate and self.mode != 'init': 357 ↛ 359line 357 didn't jump to line 359 because the condition on line 357 was never true

358 # check if the xml record has no id, skip 

359 if not rec_id: 

360 return None 

361 

362 if record := env['ir.model.data']._load_xmlid(xid): 

363 for child in rec.xpath('.//record[@id]'): 

364 sub_xid = child.get("id") 

365 self._test_xml_id(sub_xid) 

366 sub_xid = self.make_xml_id(sub_xid) 

367 if sub_record := env['ir.model.data']._load_xmlid(sub_xid): 

368 self.idref[sub_xid] = sub_record.id 

369 

370 # if the resource already exists, don't update it but store 

371 # its database id (can be useful) 

372 self.idref[xid] = record.id 

373 return None 

374 elif not nodeattr2bool(rec, 'forcecreate', True): 

375 # if it doesn't exist and we shouldn't create it, skip it 

376 return None 

377 # else create it normally 

378 

379 foreign_record_to_create = False 

380 if xid and xid.partition('.')[0] != self.module: 

381 # updating a record created by another module 

382 record = self.env['ir.model.data']._load_xmlid(xid) 

383 if not record and not (foreign_record_to_create := nodeattr2bool(rec, 'forcecreate')): # Allow foreign records if explicitely stated 383 ↛ 384line 383 didn't jump to line 384 because the condition on line 383 was never true

384 if self.noupdate and not nodeattr2bool(rec, 'forcecreate', True): 

385 # if it doesn't exist and we shouldn't create it, skip it 

386 return None 

387 raise Exception("Cannot update missing record %r" % xid) 

388 

389 from odoo.fields import Command # noqa: PLC0415 

390 res = {} 

391 sub_records = [] 

392 for field in rec.iterchildren('field'): 

393 #TODO: most of this code is duplicated above (in _eval_xml)... 

394 f_name = field.get("name") 

395 if '@' in f_name: 

396 continue # used for translations 

397 f_model = field.get("model") 

398 if not f_model and f_name in model._fields: 

399 f_model = model._fields[f_name].comodel_name 

400 f_use = field.get("use",'') or 'id' 

401 f_val = False 

402 

403 if f_search := field.get("search"): 

404 context = _get_eval_context(self, env, f_model) 

405 q = safe_eval(f_search, context) 

406 assert f_model, 'Define an attribute model="..." in your .XML file!' 

407 # browse the objects searched 

408 s = env[f_model].search(q) 

409 # column definitions of the "local" object 

410 _fields = env[rec_model]._fields 

411 # if the current field is many2many 

412 if (f_name in _fields) and _fields[f_name].type == 'many2many': 

413 f_val = [Command.set([x[f_use] for x in s])] 

414 elif len(s): 414 ↛ 449line 414 didn't jump to line 449 because the condition on line 414 was always true

415 # otherwise (we are probably in a many2one field), 

416 # take the first element of the search 

417 f_val = s[0][f_use] 

418 elif f_ref := field.get("ref"): 

419 if f_name in model._fields and model._fields[f_name].type == 'reference': 419 ↛ 420line 419 didn't jump to line 420 because the condition on line 419 was never true

420 val = self.model_id_get(f_ref) 

421 f_val = val[0] + ',' + str(val[1]) 

422 else: 

423 f_val = self.id_get(f_ref, raise_if_not_found=nodeattr2bool(rec, 'forcecreate', True)) 

424 if not f_val: 424 ↛ 425line 424 didn't jump to line 425 because the condition on line 424 was never true

425 _logger.warning("Skipping creation of %r because %s=%r could not be resolved", xid, f_name, f_ref) 

426 return None 

427 else: 

428 f_val = _eval_xml(self, field, env) 

429 if f_name in model._fields: 429 ↛ 449line 429 didn't jump to line 449 because the condition on line 429 was always true

430 field_type = model._fields[f_name].type 

431 if field_type == 'many2one': 

432 f_val = int(f_val) if f_val else False 

433 elif field_type == 'integer': 

434 f_val = int(f_val) 

435 elif field_type in ('float', 'monetary'): 

436 f_val = float(f_val) 

437 elif field_type == 'boolean' and isinstance(f_val, str): 

438 f_val = str2bool(f_val) 

439 elif field_type == 'one2many': 

440 for child in field.iterchildren('record'): 

441 sub_records.append((child, model._fields[f_name].inverse_name)) 

442 if isinstance(f_val, str): 

443 # We do not want to write on the field since we will write 

444 # on the childrens' parents later 

445 continue 

446 elif field_type == 'html': 

447 if field.get('type') == 'xml': 447 ↛ 448line 447 didn't jump to line 448 because the condition on line 447 was never true

448 _logger.warning('HTML field %r is declared as `type="xml"`', f_name) 

449 res[f_name] = f_val 

450 if extra_vals: 

451 res.update(extra_vals) 

452 if 'sequence' not in res and 'sequence' in model._fields: 

453 sequence = self.next_sequence() 

454 if sequence: 

455 res['sequence'] = sequence 

456 

457 data = dict(xml_id=xid, values=res, noupdate=self.noupdate) 

458 if foreign_record_to_create: 

459 model = model.with_context(foreign_record_to_create=foreign_record_to_create) 

460 record = model._load_records([data], self.mode == 'update') 

461 if xid: 461 ↛ 463line 461 didn't jump to line 463 because the condition on line 461 was always true

462 self.idref[xid] = record.id 

463 if config.get('import_partial'): 463 ↛ 464line 463 didn't jump to line 464 because the condition on line 463 was never true

464 env.cr.commit() 

465 for child_rec, inverse_name in sub_records: 

466 self._tag_record(child_rec, extra_vals={inverse_name: record.id}) 

467 return rec_model, record.id 

468 

469 def _tag_template(self, el): 

470 # This helper transforms a <template> element into a <record> and forwards it 

471 tpl_id = el.get('id', el.get('t-name')) 

472 full_tpl_id = tpl_id 

473 if '.' not in full_tpl_id: 

474 full_tpl_id = '%s.%s' % (self.module, tpl_id) 

475 # set the full template name for qweb <module>.<id> 

476 if not el.get('inherit_id'): 

477 el.set('t-name', full_tpl_id) 

478 el.tag = 't' 

479 else: 

480 el.tag = 'data' 

481 el.attrib.pop('id', None) 

482 

483 if self.module.startswith('theme_'): 483 ↛ 484line 483 didn't jump to line 484 because the condition on line 483 was never true

484 model = 'theme.ir.ui.view' 

485 else: 

486 model = 'ir.ui.view' 

487 

488 record_attrs = { 

489 'id': tpl_id, 

490 'model': model, 

491 } 

492 for att in ['forcecreate', 'context']: 

493 if att in el.attrib: 493 ↛ 494line 493 didn't jump to line 494 because the condition on line 493 was never true

494 record_attrs[att] = el.attrib.pop(att) 

495 

496 Field = builder.E.field 

497 name = el.get('name', tpl_id) 

498 

499 record = etree.Element('record', attrib=record_attrs) 

500 record.append(Field(name, name='name')) 

501 record.append(Field(full_tpl_id, name='key')) 

502 record.append(Field("qweb", name='type')) 

503 if 'track' in el.attrib: 

504 record.append(Field(el.get('track'), name='track')) 

505 if 'priority' in el.attrib: 

506 record.append(Field(el.get('priority'), name='priority')) 

507 if 'inherit_id' in el.attrib: 

508 record.append(Field(name='inherit_id', ref=el.get('inherit_id'))) 

509 if 'website_id' in el.attrib: 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true

510 record.append(Field(name='website_id', ref=el.get('website_id'))) 

511 if 'key' in el.attrib: 511 ↛ 512line 511 didn't jump to line 512 because the condition on line 511 was never true

512 record.append(Field(el.get('key'), name='key')) 

513 

514 # If the "active" value is set on the root node (instead of an inner 

515 # <field>), it is treated as the value for the "active" field but only 

516 # when *not updating*. This allows to update the record in a more recent 

517 # version without changing its active state (compatibility). 

518 if el.get('active') in ("True", "False"): 

519 view_id = self.id_get(tpl_id, raise_if_not_found=False) 

520 if self.mode != "update" or not view_id: 520 ↛ 523line 520 didn't jump to line 523 because the condition on line 520 was always true

521 record.append(Field(name='active', eval=el.get('active'))) 

522 

523 if el.get('customize_show') in ("True", "False"): 

524 record.append(Field(name='customize_show', eval=el.get('customize_show'))) 

525 groups = el.attrib.pop('groups', None) 

526 if groups: 

527 grp_lst = [("ref('%s')" % x) for x in groups.split(',')] 

528 record.append(Field(name="group_ids", eval="[Command.set(["+', '.join(grp_lst)+"])]")) 

529 if el.get('primary') == 'True': 

530 # Pseudo clone mode, we'll set the t-name to the full canonical xmlid 

531 el.append( 

532 builder.E.xpath( 

533 builder.E.attribute(full_tpl_id, name='t-name'), 

534 expr=".", 

535 position="attributes", 

536 ) 

537 ) 

538 record.append(Field('primary', name='mode')) 

539 # inject complete <template> element (after changing node name) into 

540 # the ``arch`` field 

541 record.append(Field(el, name="arch", type="xml")) 

542 

543 return self._tag_record(record) 

544 

545 def _tag_asset(self, el): 

546 """ 

547 Transforms an <asset> element into a <record> and forwards it. 

548 """ 

549 asset_id = el.get('id') 

550 Field = builder.E.field 

551 

552 record = etree.Element('record', attrib={ 

553 'id': asset_id, 

554 'model': 'theme.ir.asset' if self.module.startswith('theme_') else 'ir.asset', 

555 }) 

556 

557 name = el.get('name', asset_id) 

558 record.append(Field(name, name='name')) 

559 

560 # E.g. <bundle directive="prepend">web.assets_frontend</bundle> 

561 # (directive is optional) 

562 bundle_el = el.find('bundle') 

563 record.append(Field(bundle_el.text, name='bundle')) 

564 if 'directive' in bundle_el.attrib: 

565 record.append(Field(bundle_el.get('directive'), name='directive')) 

566 

567 # E.g. <path>website/static/src/snippets/s_share/000.scss</path> 

568 record.append(Field(el.find('path').text, name='path')) 

569 

570 # Same as <template> for ir.ui.view: 

571 # If the "active" value is set on the root node (instead of an inner 

572 # <field>), it is treated as the value for the "active" field but only 

573 # when *not updating*. This allows to update the record in a more recent 

574 # version without changing its active state (compatibility). 

575 if el.get('active') in ("True", "False"): 

576 record_id = self.id_get(asset_id, raise_if_not_found=False) 

577 if self.mode != "update" or not record_id: 577 ↛ 580line 577 didn't jump to line 580 because the condition on line 577 was always true

578 record.append(Field(name='active', eval=el.get('active'))) 

579 

580 for child in el.iterchildren('field'): 

581 record.append(child) 

582 

583 return self._tag_record(record) 

584 

585 def id_get(self, id_str, raise_if_not_found=True): 

586 id_str = self.make_xml_id(id_str) 

587 if id_str in self.idref: 

588 return self.idref[id_str] 

589 return self.model_id_get(id_str, raise_if_not_found)[1] 

590 

591 def model_id_get(self, id_str, raise_if_not_found=True): 

592 id_str = self.make_xml_id(id_str) 

593 return self.env['ir.model.data']._xmlid_to_res_model_res_id(id_str, raise_if_not_found=raise_if_not_found) 

594 

595 def _tag_root(self, el): 

596 for rec in el: 

597 f = self._tags.get(rec.tag) 

598 if f is None: 

599 continue 

600 

601 self.envs.append(self.get_env(el)) 

602 self._noupdate.append(nodeattr2bool(el, 'noupdate', self.noupdate)) 

603 self._sequences.append(0 if nodeattr2bool(el, 'auto_sequence', False) else None) 

604 try: 

605 f(rec) 

606 except ParseError: 

607 raise 

608 except ValidationError as err: 

609 msg = "while parsing {file}:{viewline}\n{err}\n\nView error context:\n{context}\n".format( 

610 file=rec.getroottree().docinfo.URL, 

611 viewline=rec.sourceline, 

612 context=pprint.pformat(getattr(err, 'context', None) or '-no context-'), 

613 err=err.args[0], 

614 ) 

615 _logger.debug(msg, exc_info=True) 

616 raise ParseError(msg) from None # Restart with "--log-handler odoo.tools.convert:DEBUG" for complete traceback 

617 except Exception as e: 

618 raise ParseError('while parsing %s:%s, somewhere inside\n%s' % ( 

619 rec.getroottree().docinfo.URL, 

620 rec.sourceline, 

621 etree.tostring(rec, encoding='unicode').rstrip() 

622 )) from e 

623 finally: 

624 self._noupdate.pop() 

625 self.envs.pop() 

626 self._sequences.pop() 

627 

628 @property 

629 def env(self): 

630 return self.envs[-1] 

631 

632 @property 

633 def noupdate(self): 

634 return self._noupdate[-1] 

635 

636 def next_sequence(self): 

637 value = self._sequences[-1] 

638 if value is not None: 

639 value = self._sequences[-1] = value + 10 

640 return value 

641 

642 def __init__(self, env, module, idref: Optional[IdRef], mode: ConvertMode, noupdate: bool = False, xml_filename: str = ''): 

643 self.mode = mode 

644 self.module = module 

645 self.envs = [env(context=dict(env.context, lang=None))] 

646 self.idref: IdRef = {} if idref is None else idref 

647 self._noupdate = [noupdate] 

648 self._sequences = [] 

649 self.xml_filename = xml_filename 

650 self._tags = { 

651 'record': self._tag_record, 

652 'delete': self._tag_delete, 

653 'function': self._tag_function, 

654 'menuitem': self._tag_menuitem, 

655 'template': self._tag_template, 

656 'asset': self._tag_asset, 

657 

658 **dict.fromkeys(self.DATA_ROOTS, self._tag_root) 

659 } 

660 

661 def parse(self, de): 

662 assert de.tag in self.DATA_ROOTS, "Root xml tag must be <openerp>, <odoo> or <data>." 

663 self._tag_root(de) 

664 DATA_ROOTS = ['odoo', 'data', 'openerp'] 

665 

666 

667def convert_file( 

668 env, 

669 module, 

670 filename, 

671 idref: Optional[IdRef], 

672 mode: ConvertMode = 'update', 

673 noupdate=False, 

674 kind=None, 

675 pathname=None, 

676): 

677 if kind is not None: 677 ↛ 678line 677 didn't jump to line 678 because the condition on line 677 was never true

678 warnings.warn( 

679 "The `kind` argument is deprecated in Odoo 19.", 

680 DeprecationWarning, 

681 stacklevel=2, 

682 ) 

683 if pathname is None: 683 ↛ 685line 683 didn't jump to line 685 because the condition on line 683 was always true

684 pathname = os.path.join(module, filename) 

685 ext = os.path.splitext(filename)[1].lower() 

686 

687 with file_open(pathname, 'rb', env=env) as fp: 

688 if ext == '.csv': 

689 convert_csv_import(env, module, pathname, fp.read(), idref, mode, noupdate) 

690 elif ext == '.sql': 690 ↛ 691line 690 didn't jump to line 691 because the condition on line 690 was never true

691 convert_sql_import(env, fp) 

692 elif ext == '.xml': 692 ↛ 694line 692 didn't jump to line 694 because the condition on line 692 was always true

693 convert_xml_import(env, module, fp, idref, mode, noupdate) 

694 elif ext == '.js': 

695 pass # .js files are valid but ignored here. 

696 else: 

697 raise ValueError("Can't load unknown file type %s.", filename) 

698 

699 

700def convert_sql_import(env, fp): 

701 env.cr.execute(fp.read()) # pylint: disable=sql-injection 

702 

703 

704def convert_csv_import( 

705 env, 

706 module, 

707 fname, 

708 csvcontent, 

709 idref: Optional[IdRef] = None, 

710 mode: ConvertMode = 'init', 

711 noupdate=False, 

712): 

713 '''Import csv file : 

714 quote: " 

715 delimiter: , 

716 encoding: utf-8''' 

717 env = env(context=dict(env.context, lang=None)) 

718 filename, _ext = os.path.splitext(os.path.basename(fname)) 

719 model = filename.split('-')[0] 

720 reader = csv.reader(io.StringIO(csvcontent.decode()), quotechar='"', delimiter=',') 

721 fields = next(reader) 

722 

723 if not (mode == 'init' or 'id' in fields): 723 ↛ 724line 723 didn't jump to line 724 because the condition on line 723 was never true

724 _logger.error("Import specification does not contain 'id' and we are in init mode, Cannot continue.") 

725 return 

726 

727 translate_indexes = {i for i, field in enumerate(fields) if '@' in field} 

728 def remove_translations(row): 

729 return [cell for i, cell in enumerate(row) if i not in translate_indexes] 

730 

731 fields = remove_translations(fields) 

732 if not fields: 732 ↛ 733line 732 didn't jump to line 733 because the condition on line 732 was never true

733 return 

734 

735 # clean the data from translations (treated during translation import), then 

736 # filter out empty lines (any([]) == False) and lines containing only empty cells 

737 datas = [ 

738 data_line for line in reader 

739 if any(data_line := remove_translations(line)) 

740 ] 

741 

742 context = { 

743 'mode': mode, 

744 'module': module, 

745 'install_mode': True, 

746 'install_module': module, 

747 'install_filename': fname, 

748 'noupdate': noupdate, 

749 } 

750 result = env[model].with_context(**context).load(fields, datas) 

751 if any(msg['type'] == 'error' for msg in result['messages']): 751 ↛ 753line 751 didn't jump to line 753 because the condition on line 751 was never true

752 # Report failed import and abort module install 

753 warning_msg = "\n".join(msg['message'] for msg in result['messages']) 

754 raise Exception(env._( 

755 "Module loading %(module)s failed: file %(file)s could not be processed:\n%(message)s", 

756 module=module, 

757 file=fname, 

758 message=warning_msg, 

759 )) 

760 

761 

762def convert_xml_import( 

763 env, 

764 module, 

765 xmlfile, 

766 idref: Optional[IdRef] = None, 

767 mode: ConvertMode = 'init', 

768 noupdate=False, 

769 report=None, 

770): 

771 doc = etree.parse(xmlfile) 

772 schema = os.path.join(config.root_path, 'import_xml.rng') 

773 relaxng = etree.RelaxNG(etree.parse(schema)) 

774 try: 

775 relaxng.assert_(doc) 

776 except Exception: 

777 _logger.exception("The XML file '%s' does not fit the required schema!", xmlfile.name) 

778 if jingtrang: 

779 p = subprocess.run(['pyjing', schema, xmlfile.name], stdout=subprocess.PIPE) 

780 _logger.warning(p.stdout.decode()) 

781 else: 

782 for e in relaxng.error_log: 

783 _logger.warning(e) 

784 _logger.info("Install 'jingtrang' for more precise and useful validation messages.") 

785 raise 

786 

787 if isinstance(xmlfile, str): 787 ↛ 788line 787 didn't jump to line 788 because the condition on line 787 was never true

788 xml_filename = xmlfile 

789 else: 

790 xml_filename = xmlfile.name 

791 obj = xml_import(env, module, idref, mode, noupdate=noupdate, xml_filename=xml_filename) 

792 obj.parse(doc.getroot())