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.

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