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.

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