You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

615 lines
23 KiB

  1. # -*- coding: utf-8 -*-
  2. # © 2017 Therp BV <http://therp.nl>
  3. # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
  4. import logging
  5. try:
  6. import odoorpc
  7. except:
  8. logging.error('Unable to import odoorpc')
  9. import psycopg2
  10. import traceback
  11. from urlparse import urlparse
  12. from openerp import _, api, exceptions, fields, models, tools
  13. from collections import namedtuple
  14. _logger = logging.getLogger('base_import_odoo')
  15. import_context_tuple = namedtuple(
  16. 'import_context', [
  17. 'remote', 'model_line', 'ids', 'idmap', 'dummies', 'dummy_instances',
  18. 'to_delete', 'field_context',
  19. ]
  20. )
  21. class ImportContext(import_context_tuple):
  22. def with_field_context(self, *args):
  23. return ImportContext(*(self[:-1] + (field_context(*args),)))
  24. field_context = namedtuple(
  25. 'field_context', ['record_model', 'field_name', 'record_id'],
  26. )
  27. mapping_key = namedtuple('mapping_key', ['model_name', 'remote_id'])
  28. dummy_instance = namedtuple(
  29. 'dummy_instance', ['model_name', 'field_name', 'remote_id', 'dummy_id'],
  30. )
  31. class ImportOdooDatabase(models.Model):
  32. _name = 'import.odoo.database'
  33. _description = 'An Odoo database to import'
  34. url = fields.Char(required=True)
  35. database = fields.Char(required=True)
  36. user = fields.Char(default='admin', required=True)
  37. password = fields.Char(default='admin')
  38. import_line_ids = fields.One2many(
  39. 'import.odoo.database.model', 'database_id', string='Import models',
  40. )
  41. import_field_mappings = fields.One2many(
  42. 'import.odoo.database.field', 'database_id', string='Field mappings',
  43. )
  44. cronjob_id = fields.Many2one(
  45. 'ir.cron', string='Import job', readonly=True, copy=False,
  46. )
  47. cronjob_running = fields.Boolean(compute='_compute_cronjob_running')
  48. status_data = fields.Serialized('Status', readonly=True, copy=False)
  49. status_html = fields.Html(
  50. compute='_compute_status_html', readonly=True, sanitize=False,
  51. )
  52. @api.multi
  53. def action_import(self):
  54. """Create a cronjob to run the actual import"""
  55. self.ensure_one()
  56. if self.cronjob_id:
  57. return self.cronjob_id.write({
  58. 'numbercall': 1,
  59. 'doall': True,
  60. 'active': True,
  61. })
  62. return self.write({
  63. 'cronjob_id': self._create_cronjob().id,
  64. })
  65. @api.multi
  66. def _run_import(self, commit=True, commit_threshold=100):
  67. """Run the import as cronjob, commit often"""
  68. self.ensure_one()
  69. if not self.password:
  70. return
  71. # model name: [ids]
  72. remote_ids = {}
  73. # model name: count
  74. remote_counts = {}
  75. # model name: count
  76. done = {}
  77. # mapping_key: local_id
  78. idmap = {}
  79. # mapping_key: local_id
  80. # this are records created or linked when we need to fill a required
  81. # field, but the local record is not yet created
  82. dummies = {}
  83. # model name: [local_id]
  84. # this happens when we create a dummy we can throw away again
  85. to_delete = {}
  86. # dummy_instance
  87. dummy_instances = []
  88. remote = self._get_connection()
  89. self.write({'password': False})
  90. if commit and not tools.config['test_enable']:
  91. # pylint: disable=invalid-commit
  92. self.env.cr.commit()
  93. for model_line in self.import_line_ids:
  94. model = model_line.model_id
  95. remote_ids[model.model] = remote.execute(
  96. model.model, 'search',
  97. tools.safe_eval(model_line.domain) if model_line.domain else []
  98. )
  99. remote_counts[model.model] = len(remote_ids[model.model])
  100. self.write({
  101. 'status_data': {
  102. 'counts': remote_counts,
  103. 'ids': remote_ids,
  104. 'error': None,
  105. 'dummies': None,
  106. 'done': {},
  107. }
  108. })
  109. if commit and not tools.config['test_enable']:
  110. # pylint: disable=invalid-commit
  111. self.env.cr.commit()
  112. for model_line in self.import_line_ids:
  113. model = self.env[model_line.model_id.model]
  114. done[model._name] = 0
  115. chunk_len = commit and (commit_threshold or 1) or len(
  116. remote_ids[model._name]
  117. )
  118. for start_index in range(
  119. len(remote_ids[model._name]) / chunk_len + 1
  120. ):
  121. index = start_index * chunk_len
  122. ids = remote_ids[model._name][index:index + chunk_len]
  123. context = ImportContext(
  124. remote, model_line, ids, idmap, dummies, dummy_instances,
  125. to_delete, field_context(None, None, None),
  126. )
  127. try:
  128. self._run_import_model(context)
  129. except:
  130. error = traceback.format_exc()
  131. self.env.cr.rollback()
  132. self.write({
  133. 'status_data': dict(self.status_data, error=error),
  134. })
  135. # pylint: disable=invalid-commit
  136. self.env.cr.commit()
  137. raise
  138. done[model._name] += len(ids)
  139. self.write({'status_data': dict(self.status_data, done=done)})
  140. if commit and not tools.config['test_enable']:
  141. # pylint: disable=invalid-commit
  142. self.env.cr.commit()
  143. missing = {}
  144. for dummy_model, remote_id in dummies.keys():
  145. if remote_id:
  146. missing.setdefault(dummy_model, []).append(remote_id)
  147. self.write({
  148. 'status_data': dict(self.status_data, dummies=dict(missing)),
  149. })
  150. @api.multi
  151. def _run_import_model(self, context):
  152. """Import records of a configured model"""
  153. model = self.env[context.model_line.model_id.model]
  154. fields = self._run_import_model_get_fields(context)
  155. for data in context.remote.execute(
  156. model._name, 'read', context.ids, fields.keys()
  157. ):
  158. self._run_import_get_record(
  159. context, model, data, create_dummy=False,
  160. )
  161. if (model._name, data['id']) in context.idmap:
  162. # there's a mapping for this record, nothing to do
  163. continue
  164. data = self._run_import_map_values(context, data)
  165. _id = data['id']
  166. record = self._create_record(context, model, data)
  167. self._run_import_model_cleanup_dummies(
  168. context, model, _id, record.id,
  169. )
  170. @api.multi
  171. def _create_record(self, context, model, record):
  172. """Create a record, add an xmlid"""
  173. _id = record.pop('id')
  174. xmlid = '%d-%s-%d' % (
  175. self.id, model._name.replace('.', '_'), _id or 0,
  176. )
  177. if self.env.ref('base_import_odoo.%s' % xmlid, False):
  178. new = self.env.ref('base_import_odoo.%s' % xmlid)
  179. new.with_context(
  180. **self._create_record_context(model, record)
  181. ).write(record)
  182. _logger.debug('Updated record %s', xmlid)
  183. else:
  184. new = model.with_context(
  185. **self._create_record_context(model, record)
  186. ).create(record)
  187. self.env['ir.model.data'].create({
  188. 'name': xmlid,
  189. 'model': model._name,
  190. 'module': 'base_import_odoo',
  191. 'res_id': new.id,
  192. 'noupdate': True,
  193. 'import_database_id': self.id,
  194. 'import_database_record_id': _id,
  195. })
  196. _logger.debug('Created record %s', xmlid)
  197. context.idmap[mapping_key(model._name, _id)] = new.id
  198. return new
  199. def _create_record_context(self, model, record):
  200. """Return a context that is used when creating a record"""
  201. context = {
  202. 'tracking_disable': True,
  203. }
  204. if model._name == 'res.users':
  205. context['no_reset_password'] = True
  206. return context
  207. @api.multi
  208. def _run_import_get_record(
  209. self, context, model, record, create_dummy=True,
  210. ):
  211. """Find the local id of some remote record. Create a dummy if not
  212. available"""
  213. _id = context.idmap.get((model._name, record['id']))
  214. logged = False
  215. if not _id:
  216. _id = context.dummies.get((model._name, record['id']))
  217. if _id:
  218. context.dummy_instances.append(
  219. dummy_instance(*(context.field_context + (_id,)))
  220. )
  221. else:
  222. logged = True
  223. _logger.debug(
  224. 'Got %s(%d[%d]) from idmap', model._model, _id,
  225. record['id'] or 0,
  226. )
  227. if not _id:
  228. _id = self._run_import_get_record_mapping(
  229. context, model, record, create_dummy=create_dummy,
  230. )
  231. elif not logged:
  232. logged = True
  233. _logger.debug(
  234. 'Got %s(%d[%d]) from dummies', model._model, _id, record['id'],
  235. )
  236. if not _id:
  237. xmlid = self.env['ir.model.data'].search([
  238. ('import_database_id', '=', self.id),
  239. ('import_database_record_id', '=', record['id']),
  240. ('model', '=', model._name),
  241. ], limit=1)
  242. if xmlid:
  243. _id = xmlid.res_id
  244. context.idmap[(model._name, record['id'])] = _id
  245. elif not logged:
  246. logged = True
  247. _logger.debug(
  248. 'Got %s(%d[%d]) from mappings',
  249. model._model, _id, record['id'],
  250. )
  251. if not _id and create_dummy:
  252. _id = self._run_import_create_dummy(
  253. context, model, record,
  254. forcecreate=record['id'] not in
  255. self.status_data['ids'].get(model._name, [])
  256. )
  257. elif _id and not logged:
  258. _logger.debug(
  259. 'Got %s(%d[%d]) from xmlid', model._model, _id, record['id'],
  260. )
  261. return _id
  262. @api.multi
  263. def _run_import_get_record_mapping(
  264. self, context, model, record, create_dummy=True,
  265. ):
  266. current_field = self.env['ir.model.fields'].search([
  267. ('name', '=', context.field_context.field_name),
  268. ('model_id.model', '=', context.field_context.record_model),
  269. ])
  270. mappings = self.import_field_mappings.filtered(
  271. lambda x:
  272. x.mapping_type == 'fixed' and
  273. x.model_id.model == model._name and
  274. (
  275. not x.field_ids or current_field in x.field_ids
  276. ) and x.local_id and
  277. (x.remote_id == record['id'] or not x.remote_id) or
  278. x.mapping_type == 'by_field' and
  279. x.model_id.model == model._name
  280. )
  281. _id = None
  282. for mapping in mappings:
  283. if mapping.mapping_type == 'fixed':
  284. assert mapping.local_id
  285. _id = mapping.local_id
  286. context.idmap[(model._name, record['id'])] = _id
  287. break
  288. elif mapping.mapping_type == 'by_field':
  289. assert mapping.field_ids
  290. if len(record) == 1:
  291. # just the id of a record we haven't seen yet.
  292. # read the whole record from remote to check if
  293. # this can be mapped to an existing record
  294. record = context.remote.execute(
  295. model._name, 'read', record['id'],
  296. mapping.field_ids.mapped('name'),
  297. ) or None
  298. if not record:
  299. continue
  300. if isinstance(record, list):
  301. record = record[0]
  302. records = model.search([
  303. (field.name, '=', record.get(field.name))
  304. for field in mapping.field_ids
  305. ], limit=1)
  306. if records:
  307. _id = records.id
  308. context.idmap[(model._name, record['id'])] = _id
  309. break
  310. else:
  311. raise exceptions.UserError(_('Unknown mapping'))
  312. return _id
  313. @api.multi
  314. def _run_import_create_dummy(
  315. self, context, model, record, forcecreate=False,
  316. ):
  317. """Either misuse some existing record or create an empty one to satisfy
  318. required links"""
  319. dummy = model.search([
  320. (
  321. 'id', 'not in',
  322. [
  323. v for (model_name, remote_id), v
  324. in context.dummies.items()
  325. if model_name == model._name
  326. ] +
  327. [
  328. mapping.local_id for mapping
  329. in self.import_field_mappings
  330. if mapping.model_id.model == model._name and
  331. mapping.local_id
  332. ]
  333. ),
  334. ], limit=1)
  335. if dummy and not forcecreate:
  336. context.dummies[mapping_key(model._name, record['id'])] = dummy.id
  337. context.dummy_instances.append(
  338. dummy_instance(*(context.field_context + (dummy.id,)))
  339. )
  340. _logger.debug(
  341. 'Using %d as dummy for %s(%d[%d]).%s[%d]',
  342. dummy.id, context.field_context.record_model,
  343. context.idmap.get(context.field_context.record_id, 0),
  344. context.field_context.record_id,
  345. context.field_context.field_name, record['id'],
  346. )
  347. return dummy.id
  348. required = [
  349. name
  350. for name, field in model._fields.items()
  351. if field.required
  352. ]
  353. defaults = model.default_get(required)
  354. values = {'id': record['id']}
  355. for name, field in model._fields.items():
  356. if name not in required or name in defaults:
  357. continue
  358. value = None
  359. if field.type in ['char', 'text', 'html']:
  360. value = ''
  361. elif field.type in ['boolean']:
  362. value = False
  363. elif field.type in ['integer', 'float']:
  364. value = 0
  365. elif model._fields[name].type in ['date', 'datetime']:
  366. value = '2000-01-01'
  367. elif field.type in ['many2one']:
  368. if name in model._inherits.values():
  369. continue
  370. new_context = context.with_field_context(
  371. model._name, name, record['id']
  372. )
  373. value = self._run_import_get_record(
  374. new_context,
  375. self.env[model._fields[name].comodel_name],
  376. {'id': record.get(name, [None])[0]},
  377. )
  378. elif field.type in ['selection'] and not callable(field.selection):
  379. value = field.selection[0][0]
  380. elif field.type in ['selection'] and callable(field.selection):
  381. value = field.selection(model)[0][0]
  382. values[name] = value
  383. dummy = self._create_record(context, model, values)
  384. del context.idmap[mapping_key(model._name, record['id'])]
  385. context.dummies[mapping_key(model._name, record['id'])] = dummy.id
  386. context.to_delete.setdefault(model._name, [])
  387. context.to_delete[model._name].append(dummy.id)
  388. context.dummy_instances.append(
  389. dummy_instance(*(context.field_context + (dummy.id,)))
  390. )
  391. _logger.debug(
  392. 'Created %d as dummy for %s(%d[%d]).%s[%d]',
  393. dummy.id, context.field_context.record_model,
  394. context.idmap.get(context.field_context.record_id, 0),
  395. context.field_context.record_id or 0,
  396. context.field_context.field_name, record['id'],
  397. )
  398. return dummy.id
  399. @api.multi
  400. def _run_import_map_values(self, context, data):
  401. model = self.env[context.model_line.model_id.model]
  402. for field_name in data.keys():
  403. if not isinstance(
  404. model._fields[field_name], fields._Relational
  405. ) or not data[field_name]:
  406. continue
  407. if model._fields[field_name].type == 'one2many':
  408. # don't import one2many fields, use an own configuration
  409. # for this
  410. data.pop(field_name)
  411. continue
  412. ids = data[field_name] if (
  413. model._fields[field_name].type != 'many2one'
  414. ) else [data[field_name][0]]
  415. new_context = context.with_field_context(
  416. model._name, field_name, data['id']
  417. )
  418. comodel = self.env[model._fields[field_name].comodel_name]
  419. data[field_name] = [
  420. self._run_import_get_record(
  421. new_context, comodel, {'id': _id},
  422. create_dummy=model._fields[field_name].required or
  423. any(
  424. m.model_id.model == comodel._name
  425. for m in self.import_line_ids
  426. ),
  427. )
  428. for _id in ids
  429. ]
  430. data[field_name] = filter(None, data[field_name])
  431. if model._fields[field_name].type == 'many2one':
  432. if data[field_name]:
  433. data[field_name] = data[field_name] and data[field_name][0]
  434. else:
  435. data[field_name] = None
  436. else:
  437. data[field_name] = [(6, 0, data[field_name])]
  438. for mapping in self.import_field_mappings:
  439. if mapping.model_id.model != model._name:
  440. continue
  441. if mapping.mapping_type == 'unique':
  442. for field in mapping.field_ids:
  443. value = data.get(field.name, '')
  444. counter = 1
  445. while model.with_context(active_test=False).search([
  446. (field.name, '=', data.get(field.name, value)),
  447. ]):
  448. data[field.name] = '%s (%d)' % (value, counter)
  449. counter += 1
  450. elif mapping.mapping_type == 'by_reference':
  451. res_model = data.get(mapping.model_field_id.name)
  452. res_id = data.get(mapping.id_field_id.name)
  453. update = {
  454. mapping.model_field_id.name: None,
  455. mapping.id_field_id.name: None,
  456. }
  457. if res_model in self.env.registry and res_id:
  458. new_context = context.with_field_context(
  459. model._name, res_id, data['id']
  460. )
  461. record_id = self._run_import_get_record(
  462. new_context, self.env[res_model], {'id': res_id},
  463. create_dummy=False
  464. )
  465. if record_id:
  466. update.update({
  467. mapping.model_field_id.name: res_model,
  468. mapping.id_field_id.name: record_id,
  469. })
  470. data.update(update)
  471. return data
  472. @api.multi
  473. def _run_import_model_get_fields(self, context):
  474. return {
  475. name: field
  476. for name, field
  477. in self.env[context.model_line.model_id.model]._fields.items()
  478. if not field.compute or field.related
  479. }
  480. @api.multi
  481. def _run_import_model_cleanup_dummies(
  482. self, context, model, remote_id, local_id
  483. ):
  484. if not (model._name, remote_id) in context.dummies:
  485. return
  486. for instance in context.dummy_instances:
  487. key = mapping_key(instance.model_name, instance.remote_id)
  488. if key not in context.idmap:
  489. continue
  490. dummy_id = context.dummies[(model._name, remote_id)]
  491. record_model = self.env[instance.model_name]
  492. comodel = record_model._fields[instance.field_name].comodel_name
  493. if comodel != model._name or instance.dummy_id != dummy_id:
  494. continue
  495. record = record_model.browse(context.idmap[key])
  496. field_name = instance.field_name
  497. _logger.debug(
  498. 'Replacing dummy %d on %s(%d).%s with %d',
  499. dummy_id, record_model._name, record.id, field_name, local_id,
  500. )
  501. if record._fields[field_name].type == 'many2one':
  502. record.write({field_name: local_id})
  503. elif record._fields[field_name].type == 'many2many':
  504. record.write({field_name: [
  505. (3, dummy_id),
  506. (4, local_id),
  507. ]})
  508. else:
  509. raise exceptions.UserError(
  510. _('Unhandled field type %s') %
  511. record._fields[field_name].type
  512. )
  513. context.dummy_instances.remove(instance)
  514. if dummy_id in context.to_delete:
  515. model.browse(dummy_id).unlink()
  516. _logger.debug('Deleting dummy %d', dummy_id)
  517. if (model._name, remote_id) in context.dummies:
  518. del context.dummies[(model._name, remote_id)]
  519. def _get_connection(self):
  520. self.ensure_one()
  521. url = urlparse(self.url)
  522. hostport = url.netloc.split(':')
  523. if len(hostport) == 1:
  524. hostport.append('80')
  525. host, port = hostport
  526. remote = odoorpc.ODOO(
  527. host,
  528. protocol='jsonrpc+ssl' if url.scheme == 'https' else 'jsonrpc',
  529. port=int(port),
  530. )
  531. remote.login(self.database, self.user, self.password)
  532. return remote
  533. @api.constrains('url', 'database', 'user', 'password')
  534. @api.multi
  535. def _constrain_url(self):
  536. for this in self:
  537. if this == self.env.ref('base_import_odoo.demodb', False):
  538. continue
  539. if tools.config['test_enable']:
  540. continue
  541. if not this.password:
  542. continue
  543. this._get_connection()
  544. @api.depends('status_data')
  545. @api.multi
  546. def _compute_status_html(self):
  547. for this in self:
  548. if not this.status_data:
  549. continue
  550. this.status_html = self.env.ref(
  551. 'base_import_odoo.view_import_odoo_database_qweb'
  552. ).render({'object': this})
  553. @api.depends('cronjob_id')
  554. @api.multi
  555. def _compute_cronjob_running(self):
  556. for this in self:
  557. if not this.cronjob_id:
  558. continue
  559. try:
  560. with self.env.cr.savepoint():
  561. self.env.cr.execute(
  562. 'select id from "%s" where id=%%s for update nowait' %
  563. self.env['ir.cron']._table,
  564. (this.cronjob_id.id,), log_exceptions=False,
  565. )
  566. except psycopg2.OperationalError:
  567. this.cronjob_running = True
  568. @api.multi
  569. def _create_cronjob(self):
  570. self.ensure_one()
  571. return self.env['ir.cron'].create({
  572. 'name': self.display_name,
  573. 'model': self._name,
  574. 'function': '_run_import',
  575. 'doall': True,
  576. 'args': str((self.ids,)),
  577. })
  578. @api.multi
  579. def name_get(self):
  580. return [
  581. (this.id, '%s@%s, %s' % (this.user, this.url, this.database))
  582. for this in self
  583. ]