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
« 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.
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
20from dateutil.relativedelta import relativedelta
21from lxml import etree, builder
22try:
23 import jingtrang
24except ImportError:
25 jingtrang = None
27from .config import config
28from .misc import file_open, file_path, SKIPPED_ELEMENT_TYPES
29from odoo.exceptions import ValidationError
31from .safe_eval import safe_eval, pytz, time
33_logger = logging.getLogger(__name__)
35ConvertMode = Literal['init', 'update']
36IdRef = dict[str, int | Literal[False]]
38class ParseError(Exception):
39 ...
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
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.
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)
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
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))
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())
135 with file_open(node.get('file'), env=env) as f:
136 data = f.read()
137 else:
138 data = node.text or ''
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}")
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 = {}
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
205def str2bool(value):
206 return value.lower() not in ('0', 'false', 'off')
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)
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
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)
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,)
248 def _tag_delete(self, rec):
249 d_model = rec.get("model")
250 records = self.env[d_model]
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)
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)
266 if records:
267 records.unlink()
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)
275 def _tag_menuitem(self, rec, parent=None):
276 rec_id = rec.attrib["id"]
277 self._test_xml_id(rec_id)
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 }
286 if rec.get('sequence'):
287 values['sequence'] = int(rec.get('sequence'))
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']
297 if rec.get('name'):
298 values['name'] = rec.attrib['name']
300 if rec.get('action'):
301 a_action = rec.attrib['action']
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)
308 if not values.get('name') and act.type.endswith(('act_window', 'wizard', 'url', 'client', 'server')) and act.name:
309 values['name'] = act.name
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 '?'
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
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)
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", '')
341 model = env[rec_model]
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 )
351 self._test_xml_id(rec_id)
352 xid = self.make_xml_id(rec_id)
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
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
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
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)
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
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
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
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)
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'
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)
496 Field = builder.E.field
497 name = el.get('name', tpl_id)
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'))
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')))
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"))
543 return self._tag_record(record)
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
552 record = etree.Element('record', attrib={
553 'id': asset_id,
554 'model': 'theme.ir.asset' if self.module.startswith('theme_') else 'ir.asset',
555 })
557 name = el.get('name', asset_id)
558 record.append(Field(name, name='name'))
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'))
567 # E.g. <path>website/static/src/snippets/s_share/000.scss</path>
568 record.append(Field(el.find('path').text, name='path'))
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')))
580 for child in el.iterchildren('field'):
581 record.append(child)
583 return self._tag_record(record)
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]
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)
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
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()
628 @property
629 def env(self):
630 return self.envs[-1]
632 @property
633 def noupdate(self):
634 return self._noupdate[-1]
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
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,
658 **dict.fromkeys(self.DATA_ROOTS, self._tag_root)
659 }
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']
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()
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)
700def convert_sql_import(env, fp):
701 env.cr.execute(fp.read()) # pylint: disable=sql-injection
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)
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
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]
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
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 ]
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 ))
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
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())