Merge lp:~camptocamp/openerp-connector/7.0-connector-closure-functions into lp:~openerp-connector-core-editors/openerp-connector/7.0

Proposed by Guewen Baconnier @ Camptocamp on 2013-10-31
Status: Merged
Approved by: Guewen Baconnier @ Camptocamp on 2014-01-14
Approved revision: 603
Merged at revision: 604
Proposed branch: lp:~camptocamp/openerp-connector/7.0-connector-closure-functions
Merge into: lp:~openerp-connector-core-editors/openerp-connector/7.0
Diff against target: 566 lines (+393/-30)
6 files modified
connector/CHANGES.rst (+3/-0)
connector/connector.py (+5/-1)
connector/doc/guides/concepts.rst (+4/-8)
connector/tests/test_backend.py (+1/-1)
connector/tests/test_mapper.py (+118/-0)
connector/unit/mapper.py (+262/-20)
To merge this branch: bzr merge lp:~camptocamp/openerp-connector/7.0-connector-closure-functions
Reviewer Review Type Date Requested Status
Nicolas Bessi - Camptocamp (community) 2013-10-31 Needs Information on 2013-10-31
Review via email: mp+193389@code.launchpad.net

Commit message

[ADD] Added the possibility to use modifiers functions in the 'direct' mappings.

It allows to reduce the code in the @mappings methods as common modifiers can be
used. The change also include 3 modifiers: one for converting the type of the source field, one for getting the binding or openerp id of a backend field, one for getting the backend id of an openerp id or binding id.

Description of the change

Implements the blueprint: https://blueprints.launchpad.net/openerp-connector/+spec/functions-in-direct-bindings

Allows to use Modifiers in the direct mappings.

In the mappings, we have the possibility to defined 'direct' mappings. Example:

    direct = [('source_field', 'target_field')]

Integers, chars, etc. are copied. IDs of m2o relations are passed to their Binder which returns the external ID from the binding ID (or conversely returns the binding ID from the external ID).
I observed that we often need to apply a similar transformation on the source values, noted examples:
 - A backend sends '0000-00-00' for empty dates, we have to replace that by None
 - A backend sends dates in ISO 8601 format, we need to convert them to UTC then to a naive timestamp
 - A backend sends float as integer (float*100)
 - We want a default value when a value is empty
 - And in fact the most common operation is to find an external ID for a local ID and conversely, it was automatic but only for IDs of bindings
We could already do the changes using @mapping methods, but it was very repetitive.
That's why I introduced the "modifiers". Example:

    direct [(iso8601('source_date'), 'target_date'),
            (ifmissing('source_field', 'default value'), 'target_field')]

They also resolve an issue with the m2o: they were implicitly trying to search a Binder for the relation of the m2o, so not working if the relation was a "normal record". Now, I recommend to explicit the operation:

        # partner_id relation's 'res.partner' (normal record)
        # mag_partner_id relation's 'magento.res.partner' (binding)
        # s_partner_id is the id on the backend
    direct = [(backend_to_m2o('s_partner_id', binding='magento.res.partner'), 'partner_id'), # we needed to give the binding model so it can use it to find the normal ID
              (backend_to_m2o('s_partner_id'), 'mag_partner_id')] # we don't give the binding model if it is the same than the relation

Example of implementation:

    def a_function(field):
        ''' ``field`` is the name of the source field '''
        def transform(self, record, to_attr):
            ''' self is the current Mapper,
                record is the current record to map,
                to_attr is the target field'''
            return record[field]
        return transform

    direct = [
            (a_function('source'), 'target'),
    ]

To post a comment you must log in.
595. By Guewen Baconnier @ Camptocamp on 2013-10-31

[ADD] changelog

Hello,

Good idea, but if you apply a Higher-order function to your mapping is still a "direct"
mapping ? I find it a little bit confusing, but I get the finality of the change.

Maybe we may add a new "middleware or what else" class attribute that will allows to wrap any of the transformation (direct or not) with a wrapping function. Like in ring/wsgi etc webstack handler.

Regards

Nicolas

review: Needs Information
596. By Guewen Baconnier @ Camptocamp on 2013-10-31

[CHG] rename closures in modifier

597. By Guewen Baconnier @ Camptocamp on 2013-10-31

[FIX] previous revision, forgot the return...

598. By Guewen Baconnier @ Camptocamp on 2013-10-31

[ADD] 'none' modifier to convert False-ish values to None

599. By Guewen Baconnier @ Camptocamp on 2013-11-01

[FIX] when we use a modifier for the m2o and the relation is not a binding, we need to provide the name of the binding model

600. By Guewen Baconnier @ Camptocamp on 2013-11-01

[FIX] previous change: if we specify a binding model, it means that we have to wrap / unwrap the records

601. By Guewen Baconnier @ Camptocamp on 2013-11-05

[MRG] from master branch

602. By Guewen Baconnier @ Camptocamp on 2013-11-05

[ADD] new argument in the 'backend_to_m2o' modifier to allow binding on inactive records

> Hello,
>
> Good idea, but if you apply a Higher-order function to your mapping is still a
> "direct"
> mapping ? I find it a little bit confusing, but I get the finality of the
> change.

Maybe the name 'direct' was unfortunate in a first place. But that's still 'direct' in a sense: from 1 attribute to 1 attribute, we just place a modifier between them.

>
> Maybe we may add a new "middleware or what else" class attribute that will
> allows to wrap any of the transformation (direct or not) with a wrapping
> function. Like in ring/wsgi etc webstack handler.

My first opinion is that I think that will make the mappings more complex, the solution proposed here is straightforward and readable, example:

    direct = [(backend_to_m2o('deal_id'), 'offer_id'),
              (backend_to_m2o('product_id', binding='qoqa.product.template'), 'product_tmpl_id'),
              (backend_to_m2o('vat_id'), 'tax_id'),
              ('lot_size', 'lot_size'),
              ('unit_price', 'unit_price'),
              ]

Though, I don't think I get what you mean. Can you write an example of how you would use it on a Mapper?

>
> Regards
>
> Nicolas

Hello,

I was thinking about something like in Clojure transform:
(def direct {:a [inc :a]
                      :b [dec :b]
                      :c [add :a :b]
                      :d [sub :b :c]})

direct = {'offer_id': [backend_m2o, 'deal_id'],
          'product_id': [strip_id, backend_m2o, 'product_tmpl_id'],
          'cost_price': [sum, 'key_x', 'key_y']}

After discussion with Guewen I understood some use case I have missed that make uses only introspection not working.

I got your point for the variable name, maybe we should find something like one_on_one but I can leave with that.

Regards

Nicolas

603. By Guewen Baconnier @ Camptocamp on 2013-11-09

[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

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'connector/CHANGES.rst'
2--- connector/CHANGES.rst 2013-10-09 18:52:36 +0000
3+++ connector/CHANGES.rst 2013-11-09 20:51:28 +0000
4@@ -4,6 +4,9 @@
5 2.0.1.dev0
6 ~~~~~~~~~~
7
8+* Add the possibility to use 'Modifiers' functions in the 'direct
9+ mappings' (details in the documentation of the Mapper class)
10+
11 2.0.1 (2013-09-12)
12 ~~~~~~~~~~~~~~~~~~
13
14
15=== modified file 'connector/connector.py'
16--- connector/connector.py 2013-09-13 13:22:49 +0000
17+++ connector/connector.py 2013-11-09 20:51:28 +0000
18@@ -283,11 +283,15 @@
19 """
20 raise NotImplementedError
21
22- def to_backend(self, binding_id):
23+ def to_backend(self, binding_id, wrap=False):
24 """ Give the external ID for an OpenERP binding ID
25 (ID in a model magento.*)
26
27 :param binding_id: OpenERP binding ID for which we want the backend id
28+ :param wrap: if False, binding_id is the ID of the binding,
29+ if True, binding_id is the ID of the normal record, the
30+ method will search the corresponding binding and returns
31+ the backend id of the binding
32 :return: external ID of the record
33 """
34 raise NotImplementedError
35
36=== modified file 'connector/doc/guides/concepts.rst'
37--- connector/doc/guides/concepts.rst 2013-11-01 09:10:27 +0000
38+++ connector/doc/guides/concepts.rst 2013-11-09 20:51:28 +0000
39@@ -146,8 +146,7 @@
40 systems.
41
42 The connector defines some base classes, which you can find below.
43-Note that you can define your own ConnectorUnits as well without
44-reusing them.
45+Note that you can define your own ConnectorUnits as well.
46
47 Mappings
48 ========
49@@ -159,19 +158,16 @@
50
51 It supports:
52
53-* direct mappings
54-
55+direct mappings
56 Fields *a* is written in field *b*.
57
58-* method mappings
59-
60+method mappings
61 A method is used to convert one or many fields to one or many
62 fields, with transformation.
63 It can be filtered, for example only applied when the record is
64 created or when the source fields are modified.
65
66-* submapping
67-
68+submapping
69 a sub-record (lines of a sale order) is converted using another
70 Mapper
71
72
73=== modified file 'connector/tests/test_backend.py'
74--- connector/tests/test_backend.py 2013-08-23 20:34:05 +0000
75+++ connector/tests/test_backend.py 2013-11-09 20:51:28 +0000
76@@ -73,7 +73,7 @@
77 self.uid)
78
79 def tearDown(self):
80- super(test_backend_register, self).setUp()
81+ super(test_backend_register, self).tearDown()
82 BACKENDS.backends.clear()
83 del self.backend._class_entries[:]
84
85
86=== modified file 'connector/tests/test_mapper.py'
87--- connector/tests/test_mapper.py 2013-04-08 11:48:59 +0000
88+++ connector/tests/test_mapper.py 2013-11-09 20:51:28 +0000
89@@ -2,6 +2,7 @@
90
91 import unittest2
92 import mock
93+import openerp.tests.common as common
94
95 from openerp.addons.connector.unit.mapper import (
96 Mapper,
97@@ -9,8 +10,16 @@
98 MappingDefinition,
99 changed_by,
100 only_create,
101+ convert,
102+ m2o_to_backend,
103+ backend_to_m2o,
104+ none,
105 mapping)
106
107+from openerp.addons.connector.backend import Backend
108+from openerp.addons.connector.connector import Environment
109+from openerp.addons.connector.session import ConnectorSession
110+
111
112 class test_mapper(unittest2.TestCase):
113 """ Test Mapper """
114@@ -234,3 +243,112 @@
115 'discount': .5})]
116 }
117 self.assertEqual(mapper.data_for_create, expected)
118+
119+ def test_mapping_modifier(self):
120+ """ Map a direct record with a modifier function """
121+
122+ def do_nothing(field):
123+ def transform(self, record, to_attr):
124+ return record[field]
125+ return transform
126+
127+ class MyMapper(ImportMapper):
128+ direct = [(do_nothing('name'), 'out_name')]
129+
130+ env = mock.MagicMock()
131+ record = {'name': 'Guewen'}
132+ mapper = MyMapper(env)
133+ mapper.convert(record)
134+ expected = {'out_name': 'Guewen'}
135+ self.assertEqual(mapper.data, expected)
136+ self.assertEqual(mapper.data_for_create, expected)
137+
138+ def test_mapping_convert(self):
139+ """ Map a direct record with the convert modifier function """
140+ class MyMapper(ImportMapper):
141+ direct = [(convert('name', int), 'out_name')]
142+
143+ env = mock.MagicMock()
144+ record = {'name': '300'}
145+ mapper = MyMapper(env)
146+ mapper.convert(record)
147+ expected = {'out_name': 300}
148+ self.assertEqual(mapper.data, expected)
149+ self.assertEqual(mapper.data_for_create, expected)
150+
151+ def test_mapping_modifier_none(self):
152+ """ Pipeline of modifiers """
153+ class MyMapper(ImportMapper):
154+ direct = [(none('in_f'), 'out_f'),
155+ (none('in_t'), 'out_t')]
156+
157+ env = mock.MagicMock()
158+ record = {'in_f': False, 'in_t': True}
159+ mapper = MyMapper(env)
160+ mapper.convert(record)
161+ expected = {'out_f': None, 'out_t': True}
162+ self.assertEqual(mapper.data, expected)
163+ self.assertEqual(mapper.data_for_create, expected)
164+
165+ def test_mapping_modifier_pipeline(self):
166+ """ Pipeline of modifiers """
167+ class MyMapper(ImportMapper):
168+ direct = [(none(convert('in_f', bool)), 'out_f'),
169+ (none(convert('in_t', bool)), 'out_t')]
170+
171+ env = mock.MagicMock()
172+ record = {'in_f': 0, 'in_t': 1}
173+ mapper = MyMapper(env)
174+ mapper.convert(record)
175+ expected = {'out_f': None, 'out_t': True}
176+ self.assertEqual(mapper.data, expected)
177+ self.assertEqual(mapper.data_for_create, expected)
178+
179+
180+class test_mapper_binding(common.TransactionCase):
181+ """ Test Mapper with Bindings"""
182+
183+ def setUp(self):
184+ super(test_mapper_binding, self).setUp()
185+ self.session = ConnectorSession(self.cr, self.uid)
186+ self.Partner = self.registry('res.partner')
187+ self.backend = mock.Mock(wraps=Backend('x', version='y'),
188+ name='backend')
189+ backend_record = mock.Mock()
190+ backend_record.get_backend.return_value = self.backend
191+ self.env = Environment(backend_record, self.session, 'res.partner')
192+ self.country_binder = mock.Mock(name='country_binder')
193+ self.country_binder.return_value = self.country_binder
194+ self.backend.get_class.return_value = self.country_binder
195+
196+ def test_mapping_m2o_to_backend(self):
197+ """ Map a direct record with the m2o_to_backend modifier function """
198+ class MyMapper(ImportMapper):
199+ _model_name = 'res.partner'
200+ direct = [(m2o_to_backend('country_id'), 'country')]
201+
202+ partner_id = self.ref('base.main_partner')
203+ self.Partner.write(self.cr, self.uid, partner_id,
204+ {'country_id': self.ref('base.ch')})
205+ partner = self.Partner.browse(self.cr, self.uid, partner_id)
206+ self.country_binder.to_backend.return_value = 10
207+
208+ mapper = MyMapper(self.env)
209+ mapper.convert(partner)
210+ self.country_binder.to_backend.assert_called_once_with(
211+ partner.country_id.id, wrap=False)
212+ self.assertEqual(mapper.data, {'country': 10})
213+
214+ def test_mapping_backend_to_m2o(self):
215+ """ Map a direct record with the backend_to_m2o modifier function """
216+ class MyMapper(ImportMapper):
217+ _model_name = 'res.partner'
218+ direct = [(backend_to_m2o('country'), 'country_id')]
219+
220+ record = {'country': 10}
221+ self.country_binder.to_openerp.return_value = 44
222+ mapper = MyMapper(self.env)
223+ mapper.convert(record)
224+ self.country_binder.to_openerp.assert_called_once_with(
225+ 10, unwrap=False)
226+ self.assertEqual(mapper.data, {'country_id': 44})
227
228=== modified file 'connector/unit/mapper.py'
229--- connector/unit/mapper.py 2013-10-28 15:17:17 +0000
230+++ connector/unit/mapper.py 2013-11-09 20:51:28 +0000
231@@ -60,6 +60,142 @@
232 return func
233
234
235+def none(field):
236+ """ A modifier intended to be used on the ``direct`` mappings.
237+
238+ Replace the False-ish values by None.
239+ It can be used in a pipeline of modifiers when .
240+
241+ Example::
242+
243+ direct = [(none('source'), 'target'),
244+ (none(m2o_to_backend('rel_id'), 'rel_id')]
245+
246+ :param field: name of the source field in the record
247+ :param binding: True if the relation is a binding record
248+ """
249+ def modifier(self, record, to_attr):
250+ if callable(field):
251+ result = field(self, record, to_attr)
252+ else:
253+ result = record[field]
254+ if not result:
255+ return None
256+ return result
257+ return modifier
258+
259+
260+def convert(field, conv_type):
261+ """ A modifier intended to be used on the ``direct`` mappings.
262+
263+ Convert a field's value to a given type.
264+
265+ Example::
266+
267+ direct = [(convert('source', str), 'target')]
268+
269+ :param field: name of the source field in the record
270+ :param binding: True if the relation is a binding record
271+ """
272+ def modifier(self, record, to_attr):
273+ value = record[field]
274+ if not value:
275+ return False
276+ return conv_type(value)
277+ return modifier
278+
279+
280+def m2o_to_backend(field, binding=None):
281+ """ A modifier intended to be used on the ``direct`` mappings.
282+
283+ For a many2one, get the ID on the backend and returns it.
284+
285+ When the field's relation is not a binding (i.e. it does not point to
286+ something like ``magento.*``), the binding model needs to be provided
287+ in the ``binding`` keyword argument.
288+
289+ Example::
290+
291+ direct = [(m2o_to_backend('country_id', binding='magento.res.country'),
292+ 'country'),
293+ (m2o_to_backend('magento_country_id'), 'country')]
294+
295+ :param field: name of the source field in the record
296+ :param binding: name of the binding model is the relation is not a binding
297+ """
298+ def modifier(self, record, to_attr):
299+ if not record[field]:
300+ return False
301+ column = self.model._all_columns[field].column
302+ if column._type != 'many2one':
303+ raise ValueError('The column %s should be a many2one, got %s' %
304+ field, column._type)
305+ rel_id = record[field].id
306+ if binding is None:
307+ binding_model = column._obj
308+ else:
309+ binding_model = binding
310+ binder = self.get_binder_for_model(binding_model)
311+ # if a relation is not a binding, we wrap the record in the
312+ # binding, we'll return the id of the binding
313+ wrap = bool(binding)
314+ value = binder.to_backend(rel_id, wrap=wrap)
315+ if not value:
316+ raise MappingError("Can not find an external id for record "
317+ "%s in model %s %s wrapping" %
318+ (rel_id, binding_model,
319+ 'with' if wrap else 'without'))
320+ return value
321+ return modifier
322+
323+
324+def backend_to_m2o(field, binding=None, with_inactive=False):
325+ """ A modifier intended to be used on the ``direct`` mappings.
326+
327+ For a field from a backend which is an ID, search the corresponding
328+ binding in OpenERP and returns its ID.
329+
330+ When the field's relation is not a binding (i.e. it does not point to
331+ something like ``magento.*``), the binding model needs to be provided
332+ in the ``binding`` keyword argument.
333+
334+ Example::
335+
336+ direct = [(backend_to_m2o('country', binding='magento.res.country'),
337+ 'country_id'),
338+ (backend_to_m2o('country'), 'magento_country_id')]
339+
340+ :param field: name of the source field in the record
341+ :param binding: name of the binding model is the relation is not a binding
342+ :param with_inactive: include the inactive records in OpenERP in the search
343+ """
344+ def modifier(self, record, to_attr):
345+ if not record[field]:
346+ return False
347+ column = self.model._all_columns[to_attr].column
348+ if column._type != 'many2one':
349+ raise ValueError('The column %s should be a many2one, got %s' %
350+ to_attr, column._type)
351+ rel_id = record[field]
352+ if binding is None:
353+ binding_model = column._obj
354+ else:
355+ binding_model = binding
356+ binder = self.get_binder_for_model(binding_model)
357+ # if we want the ID of a normal record, not a binding,
358+ # we ask the unwrapped id to the binder
359+ unwrap = bool(binding)
360+ with self.session.change_context({'active_test': False}):
361+ value = binder.to_openerp(rel_id, unwrap=unwrap)
362+ if not value:
363+ raise MappingError("Can not find an existing %s for external "
364+ "record %s %s unwrapping" %
365+ (binding_model, rel_id,
366+ 'with' if unwrap else 'without'))
367+ return value
368+ return modifier
369+
370+
371 MappingDefinition = namedtuple('MappingDefinition',
372 ['changed_by',
373 'only_create'])
374@@ -106,7 +242,90 @@
375
376
377 class Mapper(ConnectorUnit):
378- """ Transform a record to a defined output """
379+ """ A Mapper translates an external record to an OpenERP record and
380+ conversely. The output of a Mapper is a ``dict``.
381+
382+ 3 types of mappings are supported:
383+
384+ Direct Mappings
385+ Example::
386+
387+ direct = [('source', 'target')]
388+
389+ Here, the ``source`` field will be copied in the ``target`` field.
390+
391+ A modifier can be used in the source item.
392+ The modifier will be applied to the source field before being
393+ copied in the target field.
394+ It should be a closure function respecting this idiom::
395+
396+ def a_function(field):
397+ ''' ``field`` is the name of the source field '''
398+ def modifier(self, record, to_attr):
399+ ''' self is the current Mapper,
400+ record is the current record to map,
401+ to_attr is the target field'''
402+ return record[field]
403+ return modifier
404+
405+ And used like that::
406+
407+ direct = [
408+ (a_function('source'), 'target'),
409+ ]
410+
411+ A more concrete example of modifier::
412+
413+ def convert(field, conv_type):
414+ ''' Convert the source field to a defined ``conv_type``
415+ (ex. str) before returning it'''
416+ def modifier(self, record, to_attr):
417+ value = record[field]
418+ if not value:
419+ return None
420+ return conv_type(value)
421+ return modifier
422+
423+ And used like that::
424+
425+ direct = [
426+ (convert('myfield', float), 'target_field'),
427+ ]
428+
429+ More examples of modifiers:
430+
431+ * :py:func:`convert`
432+ * :py:func:`m2o_to_backend`
433+ * :py:func:`backend_to_m2o`
434+
435+ Method Mappings
436+ A mapping method allows to execute arbitrary code and return one
437+ or many fields::
438+
439+ @mapping
440+ def compute_state(self, record):
441+ # compute some state, using the ``record`` or not
442+ state = 'pending'
443+ return {'state': state}
444+
445+ We can also specify that a mapping methods should be applied
446+ only when an object is created, and never applied on further
447+ updates::
448+
449+ @only_create
450+ @mapping
451+ def default_warehouse(self, record):
452+ # get default warehouse
453+ warehouse_id = ...
454+ return {'warehouse_id': warehouse_id}
455+
456+ Submappings
457+ When a record contains sub-items, like the lines of a sales order,
458+ we can convert the children using another Mapper::
459+
460+ children = [('items', 'line_ids', LineMapper)]
461+
462+ """
463
464 __metaclass__ = MetaMapper
465
466@@ -136,6 +355,14 @@
467 return result
468
469 def _map_direct(self, record, from_attr, to_attr):
470+ """ Apply the ``direct`` mappings.
471+
472+ :param record: record to convert from a source to a target
473+ :param from_attr: name of the source attribute or a callable
474+ :type from_attr: callable | str
475+ :param to_attr: name of the target attribute
476+ :type to_attr: str
477+ """
478 raise NotImplementedError
479
480 def _map_children(self, record, attr, model):
481@@ -157,9 +384,6 @@
482 _logger.debug('converting record %s to model %s', record, self._model_name)
483 for from_attr, to_attr in self.direct:
484 if (not fields or from_attr in fields):
485- # XXX not compatible with all
486- # record type (wrap
487- # records in a standard class representation?)
488 value = self._map_direct(record,
489 from_attr,
490 to_attr)
491@@ -262,20 +486,29 @@
492 """ Transform a record from a backend to an OpenERP record """
493
494 def _map_direct(self, record, from_attr, to_attr):
495+ """ Apply the ``direct`` mappings.
496+
497+ :param record: record to convert from a source to a target
498+ :param from_attr: name of the source attribute or a callable
499+ :type from_attr: callable | str
500+ :param to_attr: name of the target attribute
501+ :type to_attr: str
502+ """
503+ if callable(from_attr):
504+ return from_attr(self, record, to_attr)
505+
506 value = record.get(from_attr)
507 if not value:
508 return False
509
510+ # Backward compatibility: when a field is a relation, and a modifier is
511+ # not used, we assume that the relation model is a binding.
512+ # Use an explicit modifier backend_to_m2o in the 'direct' mappings to
513+ # change that.
514 column = self.model._all_columns[to_attr].column
515 if column._type == 'many2one':
516- rel_id = record[from_attr]
517- model_name = column._obj
518- binder = self.get_binder_for_model(model_name)
519- value = binder.to_openerp(rel_id)
520-
521- if not value:
522- raise MappingError("Can not find an existing %s for external "
523- "record %s" % (model_name, rel_id))
524+ mapping = backend_to_m2o(from_attr)
525+ value = mapping(self, record, to_attr)
526 return value
527
528 def _init_child_mapper(self, model_name):
529@@ -292,20 +525,29 @@
530 """ Transform a record from OpenERP to a backend record """
531
532 def _map_direct(self, record, from_attr, to_attr):
533+ """ Apply the ``direct`` mappings.
534+
535+ :param record: record to convert from a source to a target
536+ :param from_attr: name of the source attribute or a callable
537+ :type from_attr: callable | str
538+ :param to_attr: name of the target attribute
539+ :type to_attr: str
540+ """
541+ if callable(from_attr):
542+ return from_attr(self, record, to_attr)
543+
544 value = record[from_attr]
545 if not value:
546 return False
547
548+ # Backward compatibility: when a field is a relation, and a modifier is
549+ # not used, we assume that the relation model is a binding.
550+ # Use an explicit modifier m2o_to_backend in the 'direct' mappings to
551+ # change that.
552 column = self.model._all_columns[from_attr].column
553 if column._type == 'many2one':
554- rel_id = record[from_attr].id
555- model_name = column._obj
556- binder = self.get_binder_for_model(model_name)
557- value = binder.to_backend(rel_id)
558-
559- if not value:
560- raise MappingError("Can not find an external id for record "
561- "%s in model %s" % (rel_id, model_name))
562+ mapping = m2o_to_backend(from_attr)
563+ value = mapping(self, record, to_attr)
564 return value
565
566 def _init_child_mapper(self, model_name):