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.

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