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.

693 lines
26 KiB

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