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.

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