Merge lp:~openerp-connector-core-editors/openerp-connector/7.0-connector-mapper-refactor into lp:~openerp-connector-core-editors/openerp-connector/7.0

Proposed by Guewen Baconnier @ Camptocamp
Status: Superseded
Proposed branch: lp:~openerp-connector-core-editors/openerp-connector/7.0-connector-mapper-refactor
Merge into: lp:~openerp-connector-core-editors/openerp-connector/7.0
Diff against target: 1344 lines (+902/-187)
8 files modified
connector/CHANGES.rst (+3/-0)
connector/backend.py (+7/-4)
connector/connector.py (+5/-1)
connector/doc/guides/concepts.rst (+4/-8)
connector/exception.py (+4/-0)
connector/tests/test_backend.py (+3/-2)
connector/tests/test_mapper.py (+333/-40)
connector/unit/mapper.py (+543/-132)
To merge this branch: bzr merge lp:~openerp-connector-core-editors/openerp-connector/7.0-connector-mapper-refactor
Reviewer Review Type Date Requested Status
OpenERP Connector Core Editors Pending
Review via email: mp+194484@code.launchpad.net

This proposal has been superseded by a proposal from 2013-11-08.

Description of the change

Refactoring of the Mappers.
===========================

Work in progress.

To post a comment you must log in.
584. By Guewen Baconnier @ Camptocamp

[CHG] rename map_record.values(only_create=True) to map_record.values(for_create=True) -> less ambiguous

585. By Guewen Baconnier @ Camptocamp

[CHG] move deprecated methods, set some private methods to public (the ones that are really intended to be inherited)

586. By Guewen Baconnier @ Camptocamp

[CHG] updated the docstring according to the last developments

587. By Guewen Baconnier @ Camptocamp

[FIX] simplify the MapRecord.update() method

588. By Guewen Baconnier @ Camptocamp

[IMP] docstring

589. By Guewen Baconnier @ Camptocamp

[FIX] mapping options: use an object whose items can be accessed like attributes.

It offers a far better interface as well as for the the call to MapRecord.values() as for the usage in mapping methods

590. By Guewen Baconnier @ Camptocamp

[FIX] the backward compatibility code was outdated: binding is the name of the binding to use, to provide only when the relation is not a binding

591. By Guewen Baconnier @ Camptocamp

[CHG] MapOptions now returns None when a key is undefined, allowing a better handling in mapping methods when an option is not used

592. By Guewen Baconnier @ Camptocamp

[MRG] sync with master branch

593. By Guewen Baconnier @ Camptocamp

[MRG] sync with master branch

594. By Guewen Baconnier @ Camptocamp

[IMP] extract the build of the MapChild unit instance to allow better reusability

595. By Guewen Baconnier @ Camptocamp

[MRG] from lp:openerp-connector

Unmerged revisions

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'connector/CHANGES.rst'
--- connector/CHANGES.rst 2013-10-09 18:52:36 +0000
+++ connector/CHANGES.rst 2013-11-08 11:46:46 +0000
@@ -4,6 +4,9 @@
42.0.1.dev042.0.1.dev0
5~~~~~~~~~~5~~~~~~~~~~
66
7* Add the possibility to use 'Modifiers' functions in the 'direct
8 mappings' (details in the documentation of the Mapper class)
9
72.0.1 (2013-09-12)102.0.1 (2013-09-12)
8~~~~~~~~~~~~~~~~~~11~~~~~~~~~~~~~~~~~~
912
1013
=== modified file 'connector/backend.py'
--- connector/backend.py 2013-08-26 20:05:18 +0000
+++ connector/backend.py 2013-11-08 11:46:46 +0000
@@ -20,6 +20,7 @@
20##############################################################################20##############################################################################
21from functools import partial21from functools import partial
22from collections import namedtuple22from collections import namedtuple
23from .exception import NoConnectorUnitError
2324
24__all__ = ['Backend']25__all__ = ['Backend']
2526
@@ -257,10 +258,12 @@
257 """258 """
258 matching_classes = self._get_classes(base_class, session,259 matching_classes = self._get_classes(base_class, session,
259 model_name)260 model_name)
260 assert matching_classes, ('No matching class found for %s '261 if not matching_classes:
261 'with session: %s, '262 raise NoConnectorUnitError('No matching class found for %s '
262 'model name: %s' %263 'with session: %s, '
263 (base_class, session, model_name))264 'model name: %s' %
265 (base_class, session, model_name))
266
264 assert len(matching_classes) == 1, (267 assert len(matching_classes) == 1, (
265 'Several classes found for %s '268 'Several classes found for %s '
266 'with session %s, model name: %s. Found: %s' %269 'with session %s, model name: %s. Found: %s' %
267270
=== modified file 'connector/connector.py'
--- connector/connector.py 2013-09-13 13:22:49 +0000
+++ connector/connector.py 2013-11-08 11:46:46 +0000
@@ -283,11 +283,15 @@
283 """283 """
284 raise NotImplementedError284 raise NotImplementedError
285285
286 def to_backend(self, binding_id):286 def to_backend(self, binding_id, wrap=False):
287 """ Give the external ID for an OpenERP binding ID287 """ Give the external ID for an OpenERP binding ID
288 (ID in a model magento.*)288 (ID in a model magento.*)
289289
290 :param binding_id: OpenERP binding ID for which we want the backend id290 :param binding_id: OpenERP binding ID for which we want the backend id
291 :param wrap: if False, binding_id is the ID of the binding,
292 if True, binding_id is the ID of the normal record, the
293 method will search the corresponding binding and returns
294 the backend id of the binding
291 :return: external ID of the record295 :return: external ID of the record
292 """296 """
293 raise NotImplementedError297 raise NotImplementedError
294298
=== modified file 'connector/doc/guides/concepts.rst'
--- connector/doc/guides/concepts.rst 2013-11-01 09:10:27 +0000
+++ connector/doc/guides/concepts.rst 2013-11-08 11:46:46 +0000
@@ -146,8 +146,7 @@
146systems.146systems.
147147
148The connector defines some base classes, which you can find below.148The connector defines some base classes, which you can find below.
149Note that you can define your own ConnectorUnits as well without149Note that you can define your own ConnectorUnits as well.
150reusing them.
151150
152Mappings151Mappings
153========152========
@@ -159,19 +158,16 @@
159158
160It supports:159It supports:
161160
162* direct mappings161direct mappings
163
164 Fields *a* is written in field *b*.162 Fields *a* is written in field *b*.
165163
166* method mappings164method mappings
167
168 A method is used to convert one or many fields to one or many165 A method is used to convert one or many fields to one or many
169 fields, with transformation.166 fields, with transformation.
170 It can be filtered, for example only applied when the record is167 It can be filtered, for example only applied when the record is
171 created or when the source fields are modified.168 created or when the source fields are modified.
172169
173* submapping170submapping
174
175 a sub-record (lines of a sale order) is converted using another171 a sub-record (lines of a sale order) is converted using another
176 Mapper172 Mapper
177173
178174
=== modified file 'connector/exception.py'
--- connector/exception.py 2013-06-19 14:47:17 +0000
+++ connector/exception.py 2013-11-08 11:46:46 +0000
@@ -24,6 +24,10 @@
24 """ Base Exception for the connectors """24 """ Base Exception for the connectors """
2525
2626
27class NoConnectorUnitError(ConnectorException):
28 """ No ConnectorUnit has been found """
29
30
27class InvalidDataError(ConnectorException):31class InvalidDataError(ConnectorException):
28 """ Data Invalid """32 """ Data Invalid """
2933
3034
=== modified file 'connector/tests/test_backend.py'
--- connector/tests/test_backend.py 2013-08-23 20:34:05 +0000
+++ connector/tests/test_backend.py 2013-11-08 11:46:46 +0000
@@ -6,6 +6,7 @@
6from openerp.addons.connector.backend import (Backend,6from openerp.addons.connector.backend import (Backend,
7 get_backend,7 get_backend,
8 BACKENDS)8 BACKENDS)
9from openerp.addons.connector.exception import NoConnectorUnitError
9from openerp.addons.connector.connector import (Binder,10from openerp.addons.connector.connector import (Binder,
10 ConnectorUnit)11 ConnectorUnit)
11from openerp.addons.connector.unit.mapper import (Mapper,12from openerp.addons.connector.unit.mapper import (Mapper,
@@ -73,7 +74,7 @@
73 self.uid)74 self.uid)
7475
75 def tearDown(self):76 def tearDown(self):
76 super(test_backend_register, self).setUp()77 super(test_backend_register, self).tearDown()
77 BACKENDS.backends.clear()78 BACKENDS.backends.clear()
78 del self.backend._class_entries[:]79 del self.backend._class_entries[:]
7980
@@ -110,7 +111,7 @@
110111
111 def test_no_register_error(self):112 def test_no_register_error(self):
112 """ Error when asking for a class and none is found"""113 """ Error when asking for a class and none is found"""
113 with self.assertRaises(AssertionError):114 with self.assertRaises(NoConnectorUnitError):
114 ref = self.backend.get_class(BackendAdapter,115 ref = self.backend.get_class(BackendAdapter,
115 self.session,116 self.session,
116 'res.users')117 'res.users')
117118
=== modified file 'connector/tests/test_mapper.py'
--- connector/tests/test_mapper.py 2013-04-08 11:48:59 +0000
+++ connector/tests/test_mapper.py 2013-11-08 11:46:46 +0000
@@ -2,15 +2,26 @@
22
3import unittest23import unittest2
4import mock4import mock
5import openerp.tests.common as common
56
6from openerp.addons.connector.unit.mapper import (7from openerp.addons.connector.unit.mapper import (
7 Mapper,8 Mapper,
8 ImportMapper,9 ImportMapper,
10 ImportMapChild,
9 MappingDefinition,11 MappingDefinition,
10 changed_by,12 changed_by,
11 only_create,13 only_create,
14 convert,
15 m2o_to_backend,
16 backend_to_m2o,
17 none,
12 mapping)18 mapping)
1319
20from openerp.addons.connector.exception import NoConnectorUnitError
21from openerp.addons.connector.backend import Backend
22from openerp.addons.connector.connector import Environment
23from openerp.addons.connector.session import ConnectorSession
24
1425
15class test_mapper(unittest2.TestCase):26class test_mapper(unittest2.TestCase):
16 """ Test Mapper """27 """ Test Mapper """
@@ -151,11 +162,11 @@
151 record = {'name': 'Guewen',162 record = {'name': 'Guewen',
152 'street': 'street'}163 'street': 'street'}
153 mapper = MyMapper(env)164 mapper = MyMapper(env)
154 mapper.convert(record)165 map_record = mapper.map_record(record)
155 expected = {'out_name': 'Guewen',166 expected = {'out_name': 'Guewen',
156 'out_street': 'STREET'}167 'out_street': 'STREET'}
157 self.assertEqual(mapper.data, expected)168 self.assertEqual(map_record.values(), expected)
158 self.assertEqual(mapper.data_for_create, expected)169 self.assertEqual(map_record.values(only_create=True), expected)
159170
160 def test_mapping_record_on_create(self):171 def test_mapping_record_on_create(self):
161 """ Map a record and check the result for creation of record """172 """ Map a record and check the result for creation of record """
@@ -176,61 +187,343 @@
176 record = {'name': 'Guewen',187 record = {'name': 'Guewen',
177 'street': 'street'}188 'street': 'street'}
178 mapper = MyMapper(env)189 mapper = MyMapper(env)
179 mapper.convert(record)190 map_record = mapper.map_record(record)
180 expected = {'out_name': 'Guewen',191 expected = {'out_name': 'Guewen',
181 'out_street': 'STREET'}192 'out_street': 'STREET'}
182 self.assertEqual(mapper.data, expected)193 self.assertEqual(map_record.values(), expected)
183 expected = {'out_name': 'Guewen',194 expected = {'out_name': 'Guewen',
184 'out_street': 'STREET',195 'out_street': 'STREET',
185 'out_city': 'city'}196 'out_city': 'city'}
186 self.assertEqual(mapper.data_for_create, expected)197 self.assertEqual(map_record.values(only_create=True), expected)
198
199 def test_mapping_update(self):
200 """ Force values on a map record """
201 class MyMapper(ImportMapper):
202
203 direct = [('name', 'out_name')]
204
205 @mapping
206 def street(self, record):
207 return {'out_street': record['street'].upper()}
208
209 @only_create
210 @mapping
211 def city(self, record):
212 return {'out_city': 'city'}
213
214 env = mock.MagicMock()
215 record = {'name': 'Guewen',
216 'street': 'street'}
217 mapper = MyMapper(env)
218 map_record = mapper.map_record(record)
219 map_record.update(out_city='forced')
220 map_record.update({'test': 1})
221 expected = {'out_name': 'Guewen',
222 'out_street': 'STREET',
223 'out_city': 'forced',
224 'test': 1}
225 self.assertEqual(map_record.values(), expected)
226 expected = {'out_name': 'Guewen',
227 'out_street': 'STREET',
228 'out_city': 'forced',
229 'test': 1}
230 self.assertEqual(map_record.values(only_create=True), expected)
231
232 def test_finalize(self):
233 """ Inherit _finalize to modify values """
234 class MyMapper(ImportMapper):
235
236 direct = [('name', 'out_name')]
237
238 def _finalize(self, record, values):
239 result = super(MyMapper, self)._finalize(record, values)
240 result['test'] = 'abc'
241 return result
242
243 env = mock.MagicMock()
244 record = {'name': 'Guewen',
245 'street': 'street'}
246 mapper = MyMapper(env)
247 map_record = mapper.map_record(record)
248 expected = {'out_name': 'Guewen',
249 'test': 'abc'}
250 self.assertEqual(map_record.values(), expected)
251 expected = {'out_name': 'Guewen',
252 'test': 'abc'}
253 self.assertEqual(map_record.values(only_create=True), expected)
254
255 def test_some_fields(self):
256 """ Map only a selection of fields """
257 class MyMapper(ImportMapper):
258
259 direct = [('name', 'out_name'),
260 ('street', 'out_street'),
261 ]
262
263 @changed_by('country')
264 @mapping
265 def country(self, record):
266 return {'country': 'country'}
267
268 env = mock.MagicMock()
269 record = {'name': 'Guewen',
270 'street': 'street',
271 'country': 'country'}
272 mapper = MyMapper(env)
273 map_record = mapper.map_record(record)
274 expected = {'out_name': 'Guewen',
275 'country': 'country'}
276 self.assertEqual(map_record.values(fields=['name', 'country']),
277 expected)
278 expected = {'out_name': 'Guewen',
279 'country': 'country'}
280 self.assertEqual(map_record.values(only_create=True,
281 fields=['name', 'country']),
282 expected)
283
284 def test_mapping_modifier(self):
285 """ Map a direct record with a modifier function """
286
287 def do_nothing(field):
288 def transform(self, record, to_attr):
289 return record[field]
290 return transform
291
292 class MyMapper(ImportMapper):
293 direct = [(do_nothing('name'), 'out_name')]
294
295 env = mock.MagicMock()
296 record = {'name': 'Guewen'}
297 mapper = MyMapper(env)
298 map_record = mapper.map_record(record)
299 expected = {'out_name': 'Guewen'}
300 self.assertEqual(map_record.values(), expected)
301 self.assertEqual(map_record.values(only_create=True), expected)
302
303 def test_mapping_convert(self):
304 """ Map a direct record with the convert modifier function """
305 class MyMapper(ImportMapper):
306 direct = [(convert('name', int), 'out_name')]
307
308 env = mock.MagicMock()
309 record = {'name': '300'}
310 mapper = MyMapper(env)
311 map_record = mapper.map_record(record)
312 expected = {'out_name': 300}
313 self.assertEqual(map_record.values(), expected)
314 self.assertEqual(map_record.values(only_create=True), expected)
315
316 def test_mapping_modifier_none(self):
317 """ Pipeline of modifiers """
318 class MyMapper(ImportMapper):
319 direct = [(none('in_f'), 'out_f'),
320 (none('in_t'), 'out_t')]
321
322 env = mock.MagicMock()
323 record = {'in_f': False, 'in_t': True}
324 mapper = MyMapper(env)
325 map_record = mapper.map_record(record)
326 expected = {'out_f': None, 'out_t': True}
327 self.assertEqual(map_record.values(), expected)
328 self.assertEqual(map_record.values(only_create=True), expected)
329
330 def test_mapping_modifier_pipeline(self):
331 """ Pipeline of modifiers """
332 class MyMapper(ImportMapper):
333 direct = [(none(convert('in_f', bool)), 'out_f'),
334 (none(convert('in_t', bool)), 'out_t')]
335
336 env = mock.MagicMock()
337 record = {'in_f': 0, 'in_t': 1}
338 mapper = MyMapper(env)
339 map_record = mapper.map_record(record)
340 expected = {'out_f': None, 'out_t': True}
341 self.assertEqual(map_record.values(), expected)
342 self.assertEqual(map_record.values(only_create=True), expected)
343
344 def test_mapping_custom_option(self):
345 """ Usage of custom options in mappings """
346 class MyMapper(ImportMapper):
347 @mapping
348 def any(self, record):
349 if self.options.get('custom'):
350 res = True
351 else:
352 res = False
353 return {'res': res}
354
355 env = mock.MagicMock()
356 record = {}
357 mapper = MyMapper(env)
358 map_record = mapper.map_record(record)
359 expected = {'res': True}
360 self.assertEqual(map_record.values(options=dict(custom=True)),
361 expected)
362
363
364class test_mapper_binding(common.TransactionCase):
365 """ Test Mapper with Bindings"""
366
367 def setUp(self):
368 super(test_mapper_binding, self).setUp()
369 self.session = ConnectorSession(self.cr, self.uid)
370 self.Partner = self.registry('res.partner')
371 self.backend = mock.Mock(wraps=Backend('x', version='y'),
372 name='backend')
373 backend_record = mock.Mock()
374 backend_record.get_backend.return_value = self.backend
375 self.env = Environment(backend_record, self.session, 'res.partner')
376 self.country_binder = mock.Mock(name='country_binder')
377 self.country_binder.return_value = self.country_binder
378 self.backend.get_class.return_value = self.country_binder
379
380 def test_mapping_m2o_to_backend(self):
381 """ Map a direct record with the m2o_to_backend modifier function """
382 class MyMapper(ImportMapper):
383 _model_name = 'res.partner'
384 direct = [(m2o_to_backend('country_id'), 'country')]
385
386 partner_id = self.ref('base.main_partner')
387 self.Partner.write(self.cr, self.uid, partner_id,
388 {'country_id': self.ref('base.ch')})
389 partner = self.Partner.browse(self.cr, self.uid, partner_id)
390 self.country_binder.to_backend.return_value = 10
391
392 mapper = MyMapper(self.env)
393 map_record = mapper.map_record(partner)
394 self.assertEqual(map_record.values(), {'country': 10})
395 self.country_binder.to_backend.assert_called_once_with(
396 partner.country_id.id, wrap=False)
397
398 def test_mapping_backend_to_m2o(self):
399 """ Map a direct record with the backend_to_m2o modifier function """
400 class MyMapper(ImportMapper):
401 _model_name = 'res.partner'
402 direct = [(backend_to_m2o('country'), 'country_id')]
403
404 record = {'country': 10}
405 self.country_binder.to_openerp.return_value = 44
406 mapper = MyMapper(self.env)
407 map_record = mapper.map_record(record)
408 self.assertEqual(map_record.values(), {'country_id': 44})
409 self.country_binder.to_openerp.assert_called_once_with(
410 10, unwrap=False)
411
412 def test_mapping_record_children_no_map_child(self):
413 """ Map a record with children, using default MapChild """
414
415 backend = Backend('backend', '42')
416
417 @backend
418 class LineMapper(ImportMapper):
419 _model_name = 'res.currency.rate'
420 direct = [('name', 'name')]
421
422 @mapping
423 def price(self, record):
424 return {'rate': record['rate'] * 2}
425
426 @only_create
427 @mapping
428 def discount(self, record):
429 return {'test': .5}
430
431 @backend
432 class ObjectMapper(ImportMapper):
433 _model_name = 'res.currency'
434
435 direct = [('name', 'name')]
436
437 children = [('lines', 'line_ids', 'res.currency.rate')]
438
439
440 backend_record = mock.Mock()
441 backend_record.get_backend.side_effect = lambda *a: backend
442 env = Environment(backend_record, self.session, 'res.currency')
443
444 record = {'name': 'SO1',
445 'lines': [{'name': '2013-11-07',
446 'rate': 10},
447 {'name': '2013-11-08',
448 'rate': 20}]}
449 mapper = ObjectMapper(env)
450 map_record = mapper.map_record(record)
451 expected = {'name': 'SO1',
452 'line_ids': [(0, 0, {'name': '2013-11-07',
453 'rate': 20}),
454 (0, 0, {'name': '2013-11-08',
455 'rate': 40})]
456 }
457 self.assertEqual(map_record.values(), expected)
458 expected = {'name': 'SO1',
459 'line_ids': [(0, 0, {'name': '2013-11-07',
460 'rate': 20,
461 'test': .5}),
462 (0, 0, {'name': '2013-11-08',
463 'rate': 40,
464 'test': .5})]
465 }
466 self.assertEqual(map_record.values(only_create=True), expected)
187467
188 def test_mapping_record_children(self):468 def test_mapping_record_children(self):
189 """ Map a record with children and check the result """469 """ Map a record with children, using defined MapChild """
190470
471 backend = Backend('backend', '42')
472
473 @backend
191 class LineMapper(ImportMapper):474 class LineMapper(ImportMapper):
475 _model_name = 'res.currency.rate'
192 direct = [('name', 'name')]476 direct = [('name', 'name')]
193477
194 @mapping478 @mapping
195 def price(self, record):479 def price(self, record):
196 return {'price': record['price'] * 2}480 return {'rate': record['rate'] * 2}
197481
198 @only_create482 @only_create
199 @mapping483 @mapping
200 def discount(self, record):484 def discount(self, record):
201 return {'discount': .5}485 return {'test': .5}
202486
203 class SaleMapper(ImportMapper):487 @backend
488 class SaleLineImportMapChild(ImportMapChild):
489 _model_name = 'res.currency.rate'
490 def _format_items(self, items_values):
491 return [('ABC', values) for values in items_values]
492
493
494 @backend
495 class ObjectMapper(ImportMapper):
496 _model_name = 'res.currency'
204497
205 direct = [('name', 'name')]498 direct = [('name', 'name')]
206499
207 children = [('lines', 'line_ids', 'sale.order.line')]500 children = [('lines', 'line_ids', 'res.currency.rate')]
208501
209 def _init_child_mapper(self, model_name):502
210 return LineMapper(self.environment)503 backend_record = mock.Mock()
211504 backend_record.get_backend.side_effect = lambda *a: backend
212 env = mock.MagicMock()505 env = Environment(backend_record, self.session, 'res.currency')
213506
214 record = {'name': 'SO1',507 record = {'name': 'SO1',
215 'lines': [{'name': 'Mango',508 'lines': [{'name': '2013-11-07',
216 'price': 10},509 'rate': 10},
217 {'name': 'Pumpkin',510 {'name': '2013-11-08',
218 'price': 20}]}511 'rate': 20}]}
219 mapper = SaleMapper(env)512 mapper = ObjectMapper(env)
220 mapper.convert(record)513 map_record = mapper.map_record(record)
221 expected = {'name': 'SO1',514 expected = {'name': 'SO1',
222 'line_ids': [(0, 0, {'name': 'Mango',515 'line_ids': [('ABC', {'name': '2013-11-07',
223 'price': 20}),516 'rate': 20}),
224 (0, 0, {'name': 'Pumpkin',517 ('ABC', {'name': '2013-11-08',
225 'price': 40})]518 'rate': 40})]
226 }519 }
227 self.assertEqual(mapper.data, expected)520 self.assertEqual(map_record.values(), expected)
228 expected = {'name': 'SO1',521 expected = {'name': 'SO1',
229 'line_ids': [(0, 0, {'name': 'Mango',522 'line_ids': [('ABC', {'name': '2013-11-07',
230 'price': 20,523 'rate': 20,
231 'discount': .5}),524 'test': .5}),
232 (0, 0, {'name': 'Pumpkin',525 ('ABC', {'name': '2013-11-08',
233 'price': 40,526 'rate': 40,
234 'discount': .5})]527 'test': .5})]
235 }528 }
236 self.assertEqual(mapper.data_for_create, expected)529 self.assertEqual(map_record.values(only_create=True), expected)
237530
=== modified file 'connector/unit/mapper.py'
--- connector/unit/mapper.py 2013-10-28 15:17:17 +0000
+++ connector/unit/mapper.py 2013-11-08 11:46:46 +0000
@@ -21,9 +21,10 @@
2121
22import logging22import logging
23from collections import namedtuple23from collections import namedtuple
24from contextlib import contextmanager
2425
25from ..connector import ConnectorUnit, MetaConnectorUnit, Environment26from ..connector import ConnectorUnit, MetaConnectorUnit, Environment
26from ..exception import MappingError27from ..exception import MappingError, NoConnectorUnitError
2728
28_logger = logging.getLogger(__name__)29_logger = logging.getLogger(__name__)
2930
@@ -60,6 +61,142 @@
60 return func61 return func
6162
6263
64def none(field):
65 """ A modifier intended to be used on the ``direct`` mappings.
66
67 Replace the False-ish values by None.
68 It can be used in a pipeline of modifiers when .
69
70 Example::
71
72 direct = [(none('source'), 'target'),
73 (none(m2o_to_backend('rel_id'), 'rel_id')]
74
75 :param field: name of the source field in the record
76 :param binding: True if the relation is a binding record
77 """
78 def modifier(self, record, to_attr):
79 if callable(field):
80 result = field(self, record, to_attr)
81 else:
82 result = record[field]
83 if not result:
84 return None
85 return result
86 return modifier
87
88
89def convert(field, conv_type):
90 """ A modifier intended to be used on the ``direct`` mappings.
91
92 Convert a field's value to a given type.
93
94 Example::
95
96 direct = [(convert('source', str), 'target')]
97
98 :param field: name of the source field in the record
99 :param binding: True if the relation is a binding record
100 """
101 def modifier(self, record, to_attr):
102 value = record[field]
103 if not value:
104 return False
105 return conv_type(value)
106 return modifier
107
108
109def m2o_to_backend(field, binding=None):
110 """ A modifier intended to be used on the ``direct`` mappings.
111
112 For a many2one, get the ID on the backend and returns it.
113
114 When the field's relation is not a binding (i.e. it does not point to
115 something like ``magento.*``), the binding model needs to be provided
116 in the ``binding`` keyword argument.
117
118 Example::
119
120 direct = [(m2o_to_backend('country_id', binding='magento.res.country'),
121 'country'),
122 (m2o_to_backend('magento_country_id'), 'country')]
123
124 :param field: name of the source field in the record
125 :param binding: name of the binding model is the relation is not a binding
126 """
127 def modifier(self, record, to_attr):
128 if not record[field]:
129 return False
130 column = self.model._all_columns[field].column
131 if column._type != 'many2one':
132 raise ValueError('The column %s should be a many2one, got %s' %
133 field, column._type)
134 rel_id = record[field].id
135 if binding is None:
136 binding_model = column._obj
137 else:
138 binding_model = binding
139 binder = self.get_binder_for_model(binding_model)
140 # if a relation is not a binding, we wrap the record in the
141 # binding, we'll return the id of the binding
142 wrap = bool(binding)
143 value = binder.to_backend(rel_id, wrap=wrap)
144 if not value:
145 raise MappingError("Can not find an external id for record "
146 "%s in model %s %s wrapping" %
147 (rel_id, binding_model,
148 'with' if wrap else 'without'))
149 return value
150 return modifier
151
152
153def backend_to_m2o(field, binding=None, with_inactive=False):
154 """ A modifier intended to be used on the ``direct`` mappings.
155
156 For a field from a backend which is an ID, search the corresponding
157 binding in OpenERP and returns its ID.
158
159 When the field's relation is not a binding (i.e. it does not point to
160 something like ``magento.*``), the binding model needs to be provided
161 in the ``binding`` keyword argument.
162
163 Example::
164
165 direct = [(backend_to_m2o('country', binding='magento.res.country'),
166 'country_id'),
167 (backend_to_m2o('country'), 'magento_country_id')]
168
169 :param field: name of the source field in the record
170 :param binding: name of the binding model is the relation is not a binding
171 :param with_inactive: include the inactive records in OpenERP in the search
172 """
173 def modifier(self, record, to_attr):
174 if not record[field]:
175 return False
176 column = self.model._all_columns[to_attr].column
177 if column._type != 'many2one':
178 raise ValueError('The column %s should be a many2one, got %s' %
179 to_attr, column._type)
180 rel_id = record[field]
181 if binding is None:
182 binding_model = column._obj
183 else:
184 binding_model = binding
185 binder = self.get_binder_for_model(binding_model)
186 # if we want the ID of a normal record, not a binding,
187 # we ask the unwrapped id to the binder
188 unwrap = bool(binding)
189 with self.session.change_context({'active_test': False}):
190 value = binder.to_openerp(rel_id, unwrap=unwrap)
191 if not value:
192 raise MappingError("Can not find an existing %s for external "
193 "record %s %s unwrapping" %
194 (binding_model, rel_id,
195 'with' if unwrap else 'without'))
196 return value
197 return modifier
198
199
63MappingDefinition = namedtuple('MappingDefinition',200MappingDefinition = namedtuple('MappingDefinition',
64 ['changed_by',201 ['changed_by',
65 'only_create'])202 'only_create'])
@@ -105,8 +242,189 @@
105 return cls242 return cls
106243
107244
245class MapChild(ConnectorUnit):
246 """
247
248 """
249 _model_name = None
250
251 def _child_mapper(self):
252 raise NotImplementedError
253
254 def _skip_convert_child(self, map_record):
255 """ Hook to implement in sub-classes when some child
256 records should be skipped.
257
258 The parent record is accessible in ``map_record``.
259 If it returns True, the current child record is skipped.
260
261 :param map_record: record that we are converting
262 :type map_record: :py:class:`MapRecord`
263 """
264 return False
265
266 def get_items(self, items, parent, to_attr, options):
267 mapper = self._child_mapper()
268 mapped = []
269 for item in items:
270 map_record = mapper.map_record(item, parent=parent)
271 if self._skip_convert_child(map_record):
272 continue
273 mapped.append(self._get_item_values(map_record, to_attr, options))
274 return self._format_items(mapped)
275
276 def _get_item_values(self, map_record, to_attr, options):
277 """ Get the values from the child Mappers for the items.
278
279 It can be overridden for instance to:
280
281 * Change options
282 * Use a Binder to know if an item already exists to modify an
283 existing item, rather than to add it
284
285 :param map_record: record that we are converting
286 :type map_record: :py:class:`MapRecord`
287 :param to_attr: destination field (can be used for introspecting
288 the relation)
289 :type to_attr: str
290 :param options: dict of options, herited from the main mapper
291 """
292 return map_record.values(options=options)
293
294 def _format_items(self, items_values):
295 """ Format the values of the items mapped from the child Mappers.
296
297 It can be overridden for instance to add the OpenERP
298 relationships commands ``(6, 0, [IDs])``, ...
299
300 As instance, it can be modified to handle update of existing
301 items: check if 'id' existings in values, use the ``(1, ID,
302 {values}``) command
303
304 :param items_values: list of values for the items to create
305 """
306 return items_values
307
308
309class ImportMapChild(MapChild):
310
311 def _child_mapper(self):
312 return self.get_connector_unit_for_model(ImportMapper, self.model._name)
313
314 def _format_items(self, items_values):
315 """ Format the values of the items mapped from the child Mappers.
316
317 It can be overridden for instance to add the OpenERP
318 relationships commands ``(6, 0, [IDs])``, ...
319
320 As instance, it can be modified to handle update of existing
321 items: check if 'id' existings in values, use the ``(1, ID,
322 {values}``) command
323
324 :param items_values: list of values for the items to create
325 """
326 return [(0, 0, values) for values in items_values]
327
328
329class ExportMapChild(MapChild):
330
331 def _child_mapper(self):
332 return self.get_connector_unit_for_model(ExportMapper, self.model._name)
333
334
108class Mapper(ConnectorUnit):335class Mapper(ConnectorUnit):
109 """ Transform a record to a defined output """336 """ A Mapper translates an external record to an OpenERP record and
337 conversely. The output of a Mapper is a ``dict``.
338
339 3 types of mappings are supported:
340
341 Direct Mappings
342 Example::
343
344 direct = [('source', 'target')]
345
346 Here, the ``source`` field will be copied in the ``target`` field.
347
348 A modifier can be used in the source item.
349 The modifier will be applied to the source field before being
350 copied in the target field.
351 It should be a closure function respecting this idiom::
352
353 def a_function(field):
354 ''' ``field`` is the name of the source field '''
355 def modifier(self, record, to_attr):
356 ''' self is the current Mapper,
357 record is the current record to map,
358 to_attr is the target field'''
359 return record[field]
360 return modifier
361
362 And used like that::
363
364 direct = [
365 (a_function('source'), 'target'),
366 ]
367
368 A more concrete example of modifier::
369
370 def convert(field, conv_type):
371 ''' Convert the source field to a defined ``conv_type``
372 (ex. str) before returning it'''
373 def modifier(self, record, to_attr):
374 value = record[field]
375 if not value:
376 return None
377 return conv_type(value)
378 return modifier
379
380 And used like that::
381
382 direct = [
383 (convert('myfield', float), 'target_field'),
384 ]
385
386 More examples of modifiers:
387
388 * :py:func:`convert`
389 * :py:func:`m2o_to_backend`
390 * :py:func:`backend_to_m2o`
391
392 Method Mappings
393 A mapping method allows to execute arbitrary code and return one
394 or many fields::
395
396 @mapping
397 def compute_state(self, record):
398 # compute some state, using the ``record`` or not
399 state = 'pending'
400 return {'state': state}
401
402 We can also specify that a mapping methods should be applied
403 only when an object is created, and never applied on further
404 updates::
405
406 @only_create
407 @mapping
408 def default_warehouse(self, record):
409 # get default warehouse
410 warehouse_id = ...
411 return {'warehouse_id': warehouse_id}
412
413 Submappings
414 When a record contains sub-items, like the lines of a sales order,
415 we can convert the children using another Mapper::
416
417 children = [('items', 'line_ids', LineMapper)]
418
419 Usage of a Mapper::
420
421 mapper = Mapper(env)
422 map_record = mapper.map_record()
423 values = map_record.values()
424 values = map_record.values(only_create=True)
425 values = map_record.values(fields=['name', 'street'])
426
427 """
110428
111 __metaclass__ = MetaMapper429 __metaclass__ = MetaMapper
112430
@@ -118,6 +436,8 @@
118436
119 _map_methods = None437 _map_methods = None
120438
439 _map_child_class = None
440
121 def __init__(self, environment):441 def __init__(self, environment):
122 """442 """
123443
@@ -125,191 +445,282 @@
125 :type environment: :py:class:`connector.connector.Environment`445 :type environment: :py:class:`connector.connector.Environment`
126 """446 """
127 super(Mapper, self).__init__(environment)447 super(Mapper, self).__init__(environment)
128 self._data = None448 self._options = None
129 self._data_for_create = None
130 self._data_children = None
131
132 def _init_child_mapper(self, model_name):
133 raise NotImplementedError
134449
135 def _after_mapping(self, result):450 def _after_mapping(self, result):
136 return result451 """ Mapper.convert_child() has been deprecated """
452 raise DeprecationWarning('Mapper._after_mapping() has been deprecated, '
453 'use Mapper._finalize()')
137454
138 def _map_direct(self, record, from_attr, to_attr):455 def _map_direct(self, record, from_attr, to_attr):
456 """ Apply the ``direct`` mappings.
457
458 :param record: record to convert from a source to a target
459 :param from_attr: name of the source attribute or a callable
460 :type from_attr: callable | str
461 :param to_attr: name of the target attribute
462 :type to_attr: str
463 """
139 raise NotImplementedError464 raise NotImplementedError
140465
141 def _map_children(self, record, attr, model):466 def _map_children(self, record, attr, model):
142 raise NotImplementedError467 raise NotImplementedError
143468
469 def skip_convert_child(self, record, parent_values=None):
470 """ Hook to implement in sub-classes when some child
471 records should be skipped.
472
473 This method is only relevable for child mappers.
474
475 If it returns True, the current child record is skipped."""
476 return False
477
144 @property478 @property
145 def map_methods(self):479 def map_methods(self):
146 for meth, definition in self._map_methods.iteritems():480 for meth, definition in self._map_methods.iteritems():
147 yield getattr(self, meth), definition481 yield getattr(self, meth), definition
148482
149 def _convert(self, record, fields=None, parent_values=None):483 def convert_child(self, record, parent_values=None):
150 if fields is None:484 """ Mapper.convert_child() has been deprecated """
151 fields = {}485 raise DeprecationWarning('Mapper.convert_child() has been deprecated, '
152486 'use Mapper.map_record() then '
153 self._data = {}487 'map_record.values() ')
154 self._data_for_create = {}488
155 self._data_children = {}489 def convert(self, record, fields=None):
156490 """ Mapper.convert() has been deprecated
157 _logger.debug('converting record %s to model %s', record, self._model_name)491
492 The usage of a Mapper is now::
493
494 map_record = Mapper(env).map_record(record)
495 values = map_record.values()
496
497 See :py:meth:`Mapper.map_record`, :py:meth:`MapRecord.values`
498
499 """
500 raise DeprecationWarning('Mapper.convert() has been deprecated, '
501 'use Mapper.map_record() then '
502 'map_record.values() ')
503
504 @property
505 def data(self):
506 """ Mapper.data has been deprecated
507
508 See :py:meth:`Mapper.map_record`, :py:meth:`MapRecord.values`
509
510 """
511 raise DeprecationWarning('Mapper.data has been deprecated, '
512 'use Mapper.map_record() then '
513 'map_record.values() ')
514
515 @property
516 def data_for_create(self):
517 """ Mapper.data has been deprecated
518
519 See :py:meth:`Mapper.map_record`, :py:meth:`MapRecord.values`
520 """
521 raise DeprecationWarning('Mapper.data_for_create has been deprecated, '
522 'use Mapper.map_record() then '
523 'map_record.values() ')
524
525 def _map_child(self, map_record, from_attr, to_attr, model_name):
526 assert self._map_child_class is not None, "_map_child_class required"
527 child_records = map_record.source[from_attr]
528 try:
529 mapper_child = self.get_connector_unit_for_model(
530 self._map_child_class, model_name)
531 except NoConnectorUnitError:
532 # does not force developers to use a MapChild ->
533 # will use the default one if not explicitely defined
534 env = Environment(self.backend_record,
535 self.session,
536 model_name)
537 mapper_child = self._map_child_class(env)
538 items = mapper_child.get_items(child_records, map_record,
539 to_attr, options=self.options)
540 return items
541
542 @contextmanager
543 def _mapping_options(self, options):
544 """ Change the mapping options for the Mapper.
545
546 Context Manager to use in order to alter the behavior
547 of the mapping, when using ``_apply`` or ``finalize``.
548
549 """
550 current = self._options
551 self._options = options
552 yield
553 self._options = current
554
555 @property
556 def options(self):
557 """ Should not be modified manually, only with _mapping_options.
558
559 Options can be accessed in the mapping methods.
560
561 """
562 return self._options
563
564 def map_record(self, record, parent=None):
565 """ Get a MapRecord with record, ready to be converted using the
566 current Mapper.
567
568 :param record: record to transform
569 """
570 return MapRecord(self, record, parent=parent)
571
572 def _apply(self, map_record, options=None):
573 if options is None:
574 options = {}
575 with self._mapping_options(options):
576 return self._apply_with_options(map_record)
577
578 def _apply_with_options(self, map_record):
579 assert self.options is not None, (
580 "options should be defined with '_mapping_options'")
581 _logger.debug('converting record %s to model %s',
582 map_record.source, self.model._name)
583
584 fields = self.options.get('fields')
585 only_create = self.options.get('only_create')
586 result = {}
158 for from_attr, to_attr in self.direct:587 for from_attr, to_attr in self.direct:
159 if (not fields or from_attr in fields):588 if (not fields or from_attr in fields):
160 # XXX not compatible with all589 value = self._map_direct(map_record.source,
161 # record type (wrap
162 # records in a standard class representation?)
163 value = self._map_direct(record,
164 from_attr,590 from_attr,
165 to_attr)591 to_attr)
166 self._data[to_attr] = value592 result[to_attr] = value
167593
168 for meth, definition in self.map_methods:594 for meth, definition in self.map_methods:
169 changed_by = definition.changed_by595 changed_by = definition.changed_by
170 if (not fields or not changed_by or596 if (not fields or not changed_by or
171 changed_by.intersection(fields)):597 changed_by.intersection(fields)):
172 values = meth(record)598 values = meth(map_record.source)
173 if not values:599 if not values:
174 continue600 continue
175 if not isinstance(values, dict):601 if not isinstance(values, dict):
176 raise ValueError('%s: invalid return value for the '602 raise ValueError('%s: invalid return value for the '
177 'mapping method %s' % (values, meth))603 'mapping method %s' % (values, meth))
178 if definition.only_create:604 if not definition.only_create or only_create:
179 self._data_for_create.update(values)605 result.update(values)
180 else:
181 self._data.update(values)
182606
183 for from_attr, to_attr, model_name in self.children:607 for from_attr, to_attr, model_name in self.children:
184 if (not fields or from_attr in fields):608 if (not fields or from_attr in fields):
185 self._map_child(record, from_attr, to_attr, model_name)609 result[to_attr] = self._map_child(map_record, from_attr,
186610 to_attr, model_name)
187 def skip_convert_child(self, record, parent_values=None):611
188 """ Hook to implement in sub-classes when some child612 return self._finalize(map_record, result)
189 records should be skipped.613
190614 def _finalize(self, map_record, values):
191 This method is only relevable for child mappers.615 """ Called at the end of the mapping. Can be used to
192616 modify the values before returning them.
193 If it returns True, the current child record is skipped."""617
194 return False618 :param map_record: source map_record
195619 :type map_record: :py:class:`MapRecord`
196 def convert_child(self, record, parent_values=None):620 :param values: mapped values
197 """ Transform child row contained in a main record, only621 :returns: mapped values
198 called from another Mapper.622 :rtype: dict
199623 """
200 :param parent_values: openerp record of the containing object624 return values
201 (e.g. sale_order for a sale_order_line)
202 """
203 self._convert(record, parent_values=parent_values)
204
205 def convert(self, record, fields=None):
206 """ Transform an external record to an OpenERP record or the opposite
207
208 Sometimes we want to map values only when we create the records.
209 The mapping methods have to be decorated with ``only_create`` to
210 map values only for creation of records.
211
212 :param record: record to transform
213 :param fields: list of fields to convert, if empty, all fields
214 are converted
215 """
216 self._convert(record, fields=fields)
217
218 @property
219 def data(self):
220 """ Returns a dict for a record processed by
221 :py:meth:`~_convert` """
222 if self._data is None:
223 raise ValueError('Mapper.convert should be called before '
224 'accessing the data')
225 result = self._data.copy()
226 for attr, mappers in self._data_children.iteritems():
227 child_data = [mapper.data for mapper in mappers]
228 if child_data:
229 result[attr] = self._format_child_rows(child_data)
230 return self._after_mapping(result)
231
232 @property
233 def data_for_create(self):
234 """ Returns a dict for a record processed by
235 :py:meth:`~_convert` to use only for creation of the record. """
236 if self._data is None:
237 raise ValueError('Mapper.convert should be called before '
238 'accessing the data')
239 result = self._data.copy()
240 result.update(self._data_for_create)
241 for attr, mappers in self._data_children.iteritems():
242 child_data = [mapper.data_for_create for mapper in mappers]
243 if child_data:
244 result[attr] = self._format_child_rows(child_data)
245 return self._after_mapping(result)
246
247 def _format_child_rows(self, child_records):
248 return child_records
249
250 def _map_child(self, record, from_attr, to_attr, model_name):
251 child_records = record[from_attr]
252 self._data_children[to_attr] = []
253 for child_record in child_records:
254 mapper = self._init_child_mapper(model_name)
255 if mapper.skip_convert_child(child_record, parent_values=record):
256 continue
257 mapper.convert_child(child_record, parent_values=record)
258 self._data_children[to_attr].append(mapper)
259625
260626
261class ImportMapper(Mapper):627class ImportMapper(Mapper):
262 """ Transform a record from a backend to an OpenERP record """628 """ Transform a record from a backend to an OpenERP record """
263629
630 _map_child_class = ImportMapChild
631
264 def _map_direct(self, record, from_attr, to_attr):632 def _map_direct(self, record, from_attr, to_attr):
633 """ Apply the ``direct`` mappings.
634
635 :param record: record to convert from a source to a target
636 :param from_attr: name of the source attribute or a callable
637 :type from_attr: callable | str
638 :param to_attr: name of the target attribute
639 :type to_attr: str
640 """
641 if callable(from_attr):
642 return from_attr(self, record, to_attr)
643
265 value = record.get(from_attr)644 value = record.get(from_attr)
266 if not value:645 if not value:
267 return False646 return False
268647
648 # may be replaced by the explicit backend_to_m2o
269 column = self.model._all_columns[to_attr].column649 column = self.model._all_columns[to_attr].column
270 if column._type == 'many2one':650 if column._type == 'many2one':
271 rel_id = record[from_attr]651 mapping = backend_to_m2o(from_attr, binding=True)
272 model_name = column._obj652 value = mapping(self, record, to_attr)
273 binder = self.get_binder_for_model(model_name)
274 value = binder.to_openerp(rel_id)
275
276 if not value:
277 raise MappingError("Can not find an existing %s for external "
278 "record %s" % (model_name, rel_id))
279 return value653 return value
280654
281 def _init_child_mapper(self, model_name):
282 env = Environment(self.backend_record,
283 self.session,
284 model_name)
285 return env.get_connector_unit(ImportMapper)
286
287 def _format_child_rows(self, child_records):
288 return [(0, 0, data) for data in child_records]
289
290655
291class ExportMapper(Mapper):656class ExportMapper(Mapper):
292 """ Transform a record from OpenERP to a backend record """657 """ Transform a record from OpenERP to a backend record """
293658
659 _map_child_class = ExportMapChild
660
294 def _map_direct(self, record, from_attr, to_attr):661 def _map_direct(self, record, from_attr, to_attr):
662 """ Apply the ``direct`` mappings.
663
664 :param record: record to convert from a source to a target
665 :param from_attr: name of the source attribute or a callable
666 :type from_attr: callable | str
667 :param to_attr: name of the target attribute
668 :type to_attr: str
669 """
670 if callable(from_attr):
671 return from_attr(self, record, to_attr)
672
295 value = record[from_attr]673 value = record[from_attr]
296 if not value:674 if not value:
297 return False675 return False
298676
677 # may be replaced by the explicit m2o_to_backend
299 column = self.model._all_columns[from_attr].column678 column = self.model._all_columns[from_attr].column
300 if column._type == 'many2one':679 if column._type == 'many2one':
301 rel_id = record[from_attr].id680 mapping = m2o_to_backend(from_attr, binding=True)
302 model_name = column._obj681 value = mapping(self, record, to_attr)
303 binder = self.get_binder_for_model(model_name)
304 value = binder.to_backend(rel_id)
305
306 if not value:
307 raise MappingError("Can not find an external id for record "
308 "%s in model %s" % (rel_id, model_name))
309 return value682 return value
310683
311 def _init_child_mapper(self, model_name):684
312 env = Environment(self.backend_record,685class MapRecord(object):
313 self.session,686
314 model_name)687 def __init__(self, mapper, source, parent=None):
315 return env.get_connector_unit(ExportMapper)688 self._source = source
689 self._mapper = mapper
690 self._parent = parent
691 self._forced_values = {}
692
693 @property
694 def source(self):
695 return self._source
696
697 @property
698 def parent(self):
699 return self._parent
700
701 def values(self, only_create=None, fields=None, options=None):
702 """
703
704 Sometimes we want to map values only for creation the records.
705 The mapping methods have to be decorated with ``only_create`` to
706 map values only for creation of records. Then, we must give the
707 ``only_create=True`` argument to ``values()``.
708
709 """
710 if options is None:
711 options = {}
712 if only_create is not None:
713 options['only_create'] = only_create
714 if fields is not None:
715 options['fields'] = fields
716 values = self._mapper._apply(self, options=options)
717 values.update(self._forced_values)
718 return values
719
720 def update(self, *args, **kwargs):
721 if args:
722 assert len(args) == 1, 'dict expected, got: %s' % args
723 assert isinstance(args[0], dict), 'dict expected, got %s' % args
724 self._forced_values.update(args[0])
725 if kwargs:
726 self._forced_values.update(kwargs)