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.

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