1012 lines
34 KiB
Python
1012 lines
34 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2017 Camptocamp SA
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
|
|
|
|
"""
|
|
|
|
Mappers
|
|
=======
|
|
|
|
Mappers are the components responsible to transform
|
|
external records into Odoo records and conversely.
|
|
|
|
"""
|
|
|
|
import logging
|
|
from collections import namedtuple
|
|
from contextlib import contextmanager
|
|
|
|
from odoo import models
|
|
from odoo.addons.component.core import AbstractComponent
|
|
from odoo.addons.component.exception import NoComponentError
|
|
from ..exception import MappingError
|
|
import collections
|
|
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def mapping(func):
|
|
""" Decorator, declare that a method is a mapping method.
|
|
|
|
It is then used by the :py:class:`Mapper` to convert the records.
|
|
|
|
Usage::
|
|
|
|
@mapping
|
|
def any(self, record):
|
|
return {'output_field': record['input_field']}
|
|
|
|
"""
|
|
func.is_mapping = True
|
|
return func
|
|
|
|
|
|
def changed_by(*args):
|
|
""" Decorator for the mapping methods (:py:func:`mapping`)
|
|
|
|
When fields are modified in Odoo, we want to export only the
|
|
modified fields. Using this decorator, we can specify which fields
|
|
updates should trigger which mapping method.
|
|
|
|
If ``changed_by`` is empty, the mapping is always active.
|
|
|
|
As far as possible, this decorator should be used for the exports,
|
|
thus, when we do an update on only a small number of fields on a
|
|
record, the size of the output record will be limited to only the
|
|
fields really having to be exported.
|
|
|
|
Usage::
|
|
|
|
@changed_by('input_field')
|
|
@mapping
|
|
def any(self, record):
|
|
return {'output_field': record['input_field']}
|
|
|
|
:param ``*args``: field names which trigger the mapping when modified
|
|
|
|
"""
|
|
def register_mapping(func):
|
|
func.changed_by = args
|
|
return func
|
|
return register_mapping
|
|
|
|
|
|
def only_create(func):
|
|
""" Decorator for the mapping methods (:py:func:`mapping`)
|
|
|
|
A mapping decorated with ``only_create`` means that it has to be
|
|
used only for the creation of the records.
|
|
|
|
Usage::
|
|
|
|
@only_create
|
|
@mapping
|
|
def any(self, record):
|
|
return {'output_field': record['input_field']}
|
|
|
|
"""
|
|
func.only_create = True
|
|
return func
|
|
|
|
|
|
def none(field):
|
|
""" A modifier intended to be used on the ``direct`` mappings.
|
|
|
|
Replace the False-ish values by None.
|
|
It can be used in a pipeline of modifiers when .
|
|
|
|
Example::
|
|
|
|
direct = [(none('source'), 'target'),
|
|
(none(m2o_to_external('rel_id'), 'rel_id')]
|
|
|
|
:param field: name of the source field in the record
|
|
:param binding: True if the relation is a binding record
|
|
"""
|
|
def modifier(self, record, to_attr):
|
|
if isinstance(field, collections.Callable):
|
|
result = field(self, record, to_attr)
|
|
else:
|
|
result = record[field]
|
|
if not result:
|
|
return None
|
|
return result
|
|
return modifier
|
|
|
|
|
|
def convert(field, conv_type):
|
|
""" A modifier intended to be used on the ``direct`` mappings.
|
|
|
|
Convert a field's value to a given type.
|
|
|
|
Example::
|
|
|
|
direct = [(convert('source', str), 'target')]
|
|
|
|
:param field: name of the source field in the record
|
|
:param binding: True if the relation is a binding record
|
|
"""
|
|
def modifier(self, record, to_attr):
|
|
value = record[field]
|
|
if not value:
|
|
return False
|
|
return conv_type(value)
|
|
return modifier
|
|
|
|
|
|
def m2o_to_external(field, binding=None):
|
|
""" A modifier intended to be used on the ``direct`` mappings.
|
|
|
|
For a many2one, get the external ID and returns it.
|
|
|
|
When the field's relation is not a binding (i.e. it does not point to
|
|
something like ``magento.*``), the binding model needs to be provided
|
|
in the ``binding`` keyword argument.
|
|
|
|
Example::
|
|
|
|
direct = [(m2o_to_external('country_id',
|
|
binding='magento.res.country'), 'country'),
|
|
(m2o_to_external('magento_country_id'), 'country')]
|
|
|
|
:param field: name of the source field in the record
|
|
:param binding: name of the binding model is the relation is not a binding
|
|
"""
|
|
def modifier(self, record, to_attr):
|
|
if not record[field]:
|
|
return False
|
|
column = self.model._fields[field]
|
|
if column.type != 'many2one':
|
|
raise ValueError('The column %s should be a Many2one, got %s' %
|
|
(field, type(column)))
|
|
rel_id = record[field].id
|
|
if binding is None:
|
|
binding_model = column.comodel_name
|
|
else:
|
|
binding_model = binding
|
|
binder = self.binder_for(binding_model)
|
|
# if a relation is not a binding, we wrap the record in the
|
|
# binding, we'll return the id of the binding
|
|
wrap = bool(binding)
|
|
value = binder.to_external(rel_id, wrap=wrap)
|
|
if not value:
|
|
raise MappingError("Can not find an external id for record "
|
|
"%s in model %s %s wrapping" %
|
|
(rel_id, binding_model,
|
|
'with' if wrap else 'without'))
|
|
return value
|
|
return modifier
|
|
|
|
|
|
def external_to_m2o(field, binding=None):
|
|
""" A modifier intended to be used on the ``direct`` mappings.
|
|
|
|
For a field from a backend which is an ID, search the corresponding
|
|
binding in Odoo and returns it.
|
|
|
|
When the field's relation is not a binding (i.e. it does not point to
|
|
something like ``magento.*``), the binding model needs to be provided
|
|
in the ``binding`` keyword argument.
|
|
|
|
Example::
|
|
|
|
direct = [(external_to_m2o('country', binding='magento.res.country'),
|
|
'country_id'),
|
|
(external_to_m2o('country'), 'magento_country_id')]
|
|
|
|
:param field: name of the source field in the record
|
|
:param binding: name of the binding model is the relation is not a binding
|
|
"""
|
|
def modifier(self, record, to_attr):
|
|
if not record[field]:
|
|
return False
|
|
column = self.model._fields[to_attr]
|
|
if column.type != 'many2one':
|
|
raise ValueError('The column %s should be a Many2one, got %s' %
|
|
(to_attr, type(column)))
|
|
rel_id = record[field]
|
|
if binding is None:
|
|
binding_model = column.comodel_name
|
|
else:
|
|
binding_model = binding
|
|
binder = self.binder_for(binding_model)
|
|
# if we want the normal record, not a binding,
|
|
# we ask to the binder to unwrap the binding
|
|
unwrap = bool(binding)
|
|
record = binder.to_internal(rel_id, unwrap=unwrap)
|
|
if not record:
|
|
raise MappingError("Can not find an existing %s for external "
|
|
"record %s %s unwrapping" %
|
|
(binding_model, rel_id,
|
|
'with' if unwrap else 'without'))
|
|
if isinstance(record, models.BaseModel):
|
|
return record.id
|
|
else:
|
|
_logger.debug(
|
|
'Binder for %s returned an id, '
|
|
'returning a record should be preferred.', binding_model
|
|
)
|
|
return record
|
|
return modifier
|
|
|
|
|
|
def follow_m2o_relations(field):
|
|
"""A modifier intended to be used on ``direct`` mappings.
|
|
|
|
'Follows' Many2one relations and return the final field value.
|
|
|
|
Examples:
|
|
Assuming model is ``product.product``::
|
|
|
|
direct = [
|
|
(follow_m2o_relations('product_tmpl_id.categ_id.name'), 'cat')]
|
|
|
|
:param field: field "path", using dots for relations as usual in Odoo
|
|
"""
|
|
def modifier(self, record, to_attr):
|
|
attrs = field.split('.')
|
|
value = record
|
|
for attr in attrs:
|
|
value = getattr(value, attr)
|
|
return value
|
|
return modifier
|
|
|
|
|
|
MappingDefinition = namedtuple('MappingDefinition',
|
|
['changed_by',
|
|
'only_create'])
|
|
|
|
|
|
class MapChild(AbstractComponent):
|
|
""" MapChild is responsible to convert items.
|
|
|
|
Items are sub-records of a main record.
|
|
In this example, the items are the records in ``lines``::
|
|
|
|
sales = {'name': 'SO10',
|
|
'lines': [{'product_id': 1, 'quantity': 2},
|
|
{'product_id': 2, 'quantity': 2}]}
|
|
|
|
A MapChild is always called from another :py:class:`Mapper` which
|
|
provides a ``children`` configuration.
|
|
|
|
Considering the example above, the "main" :py:class:`Mapper` would
|
|
returns something as follows::
|
|
|
|
{'name': 'SO10',
|
|
'lines': [(0, 0, {'product_id': 11, 'quantity': 2}),
|
|
(0, 0, {'product_id': 12, 'quantity': 2})]}
|
|
|
|
A MapChild is responsible to:
|
|
|
|
* Find the :py:class:`Mapper` to convert the items
|
|
* Possibly filter out some lines (can be done by inheriting
|
|
:py:meth:`skip_item`)
|
|
* Convert the items' records using the found :py:class:`Mapper`
|
|
* Format the output values to the format expected by Odoo or the
|
|
backend (as seen above with ``(0, 0, {values})``
|
|
|
|
A MapChild can be extended like any other
|
|
:py:class:`~component.core.Component`.
|
|
However, it is not mandatory to explicitly create a MapChild for
|
|
each children mapping, the default one will be used
|
|
(:py:class:`ImportMapChild` or :py:class:`ExportMapChild`).
|
|
|
|
The implementation by default does not take care of the updates: if
|
|
I import a sales order 2 times, the lines will be duplicated. This
|
|
is not a problem as long as an importation should only support the
|
|
creation (typical for sales orders). It can be implemented on a
|
|
case-by-case basis by inheriting :py:meth:`get_item_values` and
|
|
:py:meth:`format_items`.
|
|
|
|
"""
|
|
|
|
_name = 'base.map.child'
|
|
_inherit = 'base.connector'
|
|
|
|
def _child_mapper(self):
|
|
raise NotImplementedError
|
|
|
|
def skip_item(self, map_record):
|
|
""" Hook to implement in sub-classes when some child
|
|
records should be skipped.
|
|
|
|
The parent record is accessible in ``map_record``.
|
|
If it returns True, the current child record is skipped.
|
|
|
|
:param map_record: record that we are converting
|
|
:type map_record: :py:class:`MapRecord`
|
|
"""
|
|
return False
|
|
|
|
def get_items(self, items, parent, to_attr, options):
|
|
""" Returns the formatted output values of items from a main record
|
|
|
|
:param items: list of item records
|
|
:type items: list
|
|
:param parent: parent record
|
|
:param to_attr: destination field (can be used for introspecting
|
|
the relation)
|
|
:type to_attr: str
|
|
:param options: dict of options, herited from the main mapper
|
|
:return: formatted output values for the item
|
|
|
|
"""
|
|
mapper = self._child_mapper()
|
|
mapped = []
|
|
for item in items:
|
|
map_record = mapper.map_record(item, parent=parent)
|
|
if self.skip_item(map_record):
|
|
continue
|
|
item_values = self.get_item_values(map_record, to_attr, options)
|
|
if item_values:
|
|
mapped.append(item_values)
|
|
return self.format_items(mapped)
|
|
|
|
def get_item_values(self, map_record, to_attr, options):
|
|
""" Get the raw values from the child Mappers for the items.
|
|
|
|
It can be overridden for instance to:
|
|
|
|
* Change options
|
|
* Use a :py:class:`~connector.connector.Binder` to know if an
|
|
item already exists to modify an existing item, rather than to
|
|
add it
|
|
|
|
:param map_record: record that we are converting
|
|
:type map_record: :py:class:`MapRecord`
|
|
:param to_attr: destination field (can be used for introspecting
|
|
the relation)
|
|
:type to_attr: str
|
|
:param options: dict of options, herited from the main mapper
|
|
|
|
"""
|
|
return map_record.values(**options)
|
|
|
|
def format_items(self, items_values):
|
|
""" Format the values of the items mapped from the child Mappers.
|
|
|
|
It can be overridden for instance to add the Odoo
|
|
relationships commands ``(6, 0, [IDs])``, ...
|
|
|
|
As instance, it can be modified to handle update of existing
|
|
items: check if an 'id' has been defined by
|
|
:py:meth:`get_item_values` then use the ``(1, ID, {values}``)
|
|
command
|
|
|
|
:param items_values: mapped values for the items
|
|
:type items_values: list
|
|
|
|
"""
|
|
return items_values
|
|
|
|
|
|
class ImportMapChild(AbstractComponent):
|
|
""" :py:class:`MapChild` for the Imports """
|
|
|
|
_name = 'base.map.child.import'
|
|
_inherit = 'base.map.child'
|
|
_usage = 'import.map.child'
|
|
|
|
def _child_mapper(self):
|
|
return self.component(usage='import.mapper')
|
|
|
|
def format_items(self, items_values):
|
|
""" Format the values of the items mapped from the child Mappers.
|
|
|
|
It can be overridden for instance to add the Odoo
|
|
relationships commands ``(6, 0, [IDs])``, ...
|
|
|
|
As instance, it can be modified to handle update of existing
|
|
items: check if an 'id' has been defined by
|
|
:py:meth:`get_item_values` then use the ``(1, ID, {values}``)
|
|
command
|
|
|
|
:param items_values: list of values for the items to create
|
|
:type items_values: list
|
|
|
|
"""
|
|
return [(0, 0, values) for values in items_values]
|
|
|
|
|
|
class ExportMapChild(AbstractComponent):
|
|
""" :py:class:`MapChild` for the Exports """
|
|
|
|
_name = 'base.map.child.export'
|
|
_inherit = 'base.map.child'
|
|
_usage = 'export.map.child'
|
|
|
|
def _child_mapper(self):
|
|
return self.component(usage='export.mapper')
|
|
|
|
|
|
class Mapper(AbstractComponent):
|
|
""" A Mapper translates an external record to an Odoo record and
|
|
conversely. The output of a Mapper is a ``dict``.
|
|
|
|
3 types of mappings are supported:
|
|
|
|
Direct Mappings
|
|
Example::
|
|
|
|
direct = [('source', 'target')]
|
|
|
|
Here, the ``source`` field will be copied in the ``target`` field.
|
|
|
|
A modifier can be used in the source item.
|
|
The modifier will be applied to the source field before being
|
|
copied in the target field.
|
|
It should be a closure function respecting this idiom::
|
|
|
|
def a_function(field):
|
|
''' ``field`` is the name of the source field.
|
|
|
|
Naming the arg: ``field`` is required for the conversion'''
|
|
def modifier(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 modifier
|
|
|
|
And used like that::
|
|
|
|
direct = [
|
|
(a_function('source'), 'target'),
|
|
]
|
|
|
|
A more concrete example of modifier::
|
|
|
|
def convert(field, conv_type):
|
|
''' Convert the source field to a defined ``conv_type``
|
|
(ex. str) before returning it'''
|
|
def modifier(self, record, to_attr):
|
|
value = record[field]
|
|
if not value:
|
|
return None
|
|
return conv_type(value)
|
|
return modifier
|
|
|
|
And used like that::
|
|
|
|
direct = [
|
|
(convert('myfield', float), 'target_field'),
|
|
]
|
|
|
|
More examples of modifiers:
|
|
|
|
* :py:func:`convert`
|
|
* :py:func:`m2o_to_external`
|
|
* :py:func:`external_to_m2o`
|
|
|
|
Method Mappings
|
|
A mapping method allows to execute arbitrary code and return one
|
|
or many fields::
|
|
|
|
@mapping
|
|
def compute_state(self, record):
|
|
# compute some state, using the ``record`` or not
|
|
state = 'pending'
|
|
return {'state': state}
|
|
|
|
We can also specify that a mapping methods should be applied
|
|
only when an object is created, and never applied on further
|
|
updates::
|
|
|
|
@only_create
|
|
@mapping
|
|
def default_warehouse(self, record):
|
|
# get default warehouse
|
|
warehouse_id = ...
|
|
return {'warehouse_id': warehouse_id}
|
|
|
|
Submappings
|
|
When a record contains sub-items, like the lines of a sales order,
|
|
we can convert the children using another Mapper::
|
|
|
|
children = [('items', 'line_ids', 'model.name')]
|
|
|
|
It allows to create the sales order and all its lines with the
|
|
same call to :py:meth:`odoo.models.BaseModel.create()`.
|
|
|
|
When using ``children`` for items of a record, we need to create
|
|
a :py:class:`Mapper` for the model of the items, and optionally a
|
|
:py:class:`MapChild`.
|
|
|
|
Usage of a Mapper::
|
|
|
|
>>> mapper = self.component(usage='mapper')
|
|
>>> map_record = mapper.map_record(record)
|
|
>>> values = map_record.values()
|
|
>>> values = map_record.values(only_create=True)
|
|
>>> values = map_record.values(fields=['name', 'street'])
|
|
|
|
"""
|
|
|
|
_name = 'base.mapper'
|
|
_inherit = 'base.connector'
|
|
_usage = 'mapper'
|
|
|
|
direct = [] # direct conversion of a field to another (from_attr, to_attr)
|
|
children = [] # conversion of sub-records (from_attr, to_attr, model)
|
|
|
|
_map_methods = None
|
|
|
|
_map_child_usage = None
|
|
_map_child_fallback = None
|
|
|
|
@classmethod
|
|
def _build_mapper_component(cls):
|
|
""" Build a Mapper component
|
|
|
|
When a Mapper component is built, we will look into every of its bases
|
|
and look for methods decorated by ``@mapping`` or ``@changed_by``. We
|
|
keep the definitions in a ``_map_methods`` attribute for later use by
|
|
the Mapper instances.
|
|
|
|
The ``__bases__`` of a newly generated Component are of 2 kinds:
|
|
|
|
* other dynamically generated components (below 'base' and
|
|
'second.mapper')
|
|
* "real" Python classes applied on top of existing components (here
|
|
ThirdMapper)
|
|
|
|
::
|
|
|
|
>>> cls.__bases__
|
|
(<class 'odoo.addons.connector.tests.test_mapper.ThirdMapper'>,
|
|
<class 'odoo.addons.component.core.second.mapper'>,
|
|
<class 'odoo.addons.component.core.base'>)
|
|
|
|
This method traverses these bases, from the bottom to the top, and
|
|
merges the mapping definitions. It reuses the computed definitions
|
|
for the generated components (for which this code already ran), and
|
|
inspect the real classes to find mapping methods.
|
|
|
|
"""
|
|
|
|
map_methods = {}
|
|
for base in reversed(cls.__bases__):
|
|
if hasattr(base, '_map_methods'):
|
|
# this is already a dynamically generated Component, so we can
|
|
# use it's existing mappings
|
|
base_map_methods = base._map_methods or {}
|
|
for attr_name, definition in base_map_methods.items():
|
|
if attr_name in map_methods:
|
|
# Update the existing @changed_by with the content
|
|
# of each base (it is mutated in place).
|
|
mapping_changed_by = map_methods[attr_name].changed_by
|
|
mapping_changed_by.update(definition.changed_by)
|
|
# keep the last value for @only_create
|
|
if definition.only_create:
|
|
new_definition = MappingDefinition(
|
|
mapping_changed_by,
|
|
definition.only_create,
|
|
)
|
|
map_methods[attr_name] = new_definition
|
|
else:
|
|
map_methods[attr_name] = definition
|
|
else:
|
|
# this is a real class that needs to be applied upon
|
|
# the base Components
|
|
for attr_name in dir(base):
|
|
attr = getattr(base, attr_name, None)
|
|
if not getattr(attr, 'is_mapping', None):
|
|
continue
|
|
has_only_create = getattr(attr, 'only_create', False)
|
|
mapping_changed_by = set(getattr(attr, 'changed_by', ()))
|
|
|
|
# if already existing, it has been defined in an previous
|
|
# base, extend the @changed_by set
|
|
if map_methods.get(attr_name) is not None:
|
|
definition = map_methods[attr_name]
|
|
mapping_changed_by.update(definition.changed_by)
|
|
|
|
# keep the last choice for only_create
|
|
definition = MappingDefinition(mapping_changed_by,
|
|
has_only_create)
|
|
map_methods[attr_name] = definition
|
|
|
|
cls._map_methods = map_methods
|
|
|
|
@classmethod
|
|
def _complete_component_build(cls):
|
|
super(Mapper, cls)._complete_component_build()
|
|
cls._build_mapper_component()
|
|
|
|
def __init__(self, work):
|
|
super(Mapper, self).__init__(work)
|
|
self._options = None
|
|
|
|
def _map_direct(self, record, from_attr, to_attr):
|
|
""" Apply the ``direct`` mappings.
|
|
|
|
:param record: record to convert from a source to a target
|
|
:param from_attr: name of the source attribute or a callable
|
|
:type from_attr: callable | str
|
|
:param to_attr: name of the target attribute
|
|
:type to_attr: str
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
def _map_children(self, record, attr, model):
|
|
raise NotImplementedError
|
|
|
|
@property
|
|
def map_methods(self):
|
|
""" Yield all the methods decorated with ``@mapping`` """
|
|
for meth, definition in self._map_methods.items():
|
|
yield getattr(self, meth), definition
|
|
|
|
def _get_map_child_component(self, model_name):
|
|
try:
|
|
mapper_child = self.component(usage=self._map_child_usage,
|
|
model_name=model_name)
|
|
except NoComponentError:
|
|
assert self._map_child_fallback is not None, (
|
|
"_map_child_fallback required")
|
|
# does not force developers to use a MapChild ->
|
|
# will use the default one if not explicitely defined
|
|
mapper_child = self.component_by_name(
|
|
self._map_child_fallback,
|
|
model_name=model_name
|
|
)
|
|
return mapper_child
|
|
|
|
def _map_child(self, map_record, from_attr, to_attr, model_name):
|
|
""" Convert items of the record as defined by children """
|
|
assert self._map_child_usage is not None, "_map_child_usage required"
|
|
child_records = map_record.source[from_attr]
|
|
mapper_child = self._get_map_child_component(model_name)
|
|
items = mapper_child.get_items(child_records, map_record,
|
|
to_attr, options=self.options)
|
|
return items
|
|
|
|
@contextmanager
|
|
def _mapping_options(self, options):
|
|
""" Change the mapping options for the Mapper.
|
|
|
|
Context Manager to use in order to alter the behavior
|
|
of the mapping, when using ``_apply`` or ``finalize``.
|
|
|
|
"""
|
|
current = self._options
|
|
self._options = options
|
|
yield
|
|
self._options = current
|
|
|
|
@property
|
|
def options(self):
|
|
""" Options can be accessed in the mapping methods with
|
|
``self.options``. """
|
|
return self._options
|
|
|
|
def changed_by_fields(self):
|
|
""" Build a set of fields used by the mapper
|
|
|
|
It takes in account the ``direct`` fields and the fields used by
|
|
the decorator: ``changed_by``.
|
|
"""
|
|
changed_by = set()
|
|
if getattr(self, 'direct', None):
|
|
for from_attr, __ in self.direct:
|
|
fieldname = self._direct_source_field_name(from_attr)
|
|
changed_by.add(fieldname)
|
|
|
|
for method_name, method_def in self._map_methods.items():
|
|
changed_by |= method_def.changed_by
|
|
return changed_by
|
|
|
|
def _direct_source_field_name(self, direct_entry):
|
|
""" Get the mapping field name. Goes through the function modifiers.
|
|
|
|
Ex::
|
|
|
|
[(none(convert(field_name, str)), out_field_name)]
|
|
|
|
It assumes that the modifier has ``field`` as first argument like::
|
|
|
|
def modifier(field, args):
|
|
|
|
"""
|
|
fieldname = direct_entry
|
|
if isinstance(direct_entry, collections.Callable):
|
|
# Map the closure entries with variable names
|
|
cells = dict(list(zip(
|
|
direct_entry.__code__.co_freevars,
|
|
(c.cell_contents for c in direct_entry.__closure__))))
|
|
assert 'field' in cells, "Modifier without 'field' argument."
|
|
if isinstance(cells['field'], collections.Callable):
|
|
fieldname = self._direct_source_field_name(cells['field'])
|
|
else:
|
|
fieldname = cells['field']
|
|
return fieldname
|
|
|
|
def map_record(self, record, parent=None):
|
|
""" Get a :py:class:`MapRecord` with record, ready to be
|
|
converted using the current Mapper.
|
|
|
|
:param record: record to transform
|
|
:param parent: optional parent record, for items
|
|
|
|
"""
|
|
return MapRecord(self, record, parent=parent)
|
|
|
|
def _apply(self, map_record, options=None):
|
|
""" Apply the mappings on a :py:class:`MapRecord`
|
|
|
|
:param map_record: source record to convert
|
|
:type map_record: :py:class:`MapRecord`
|
|
|
|
"""
|
|
if options is None:
|
|
options = {}
|
|
with self._mapping_options(options):
|
|
return self._apply_with_options(map_record)
|
|
|
|
def _apply_with_options(self, map_record):
|
|
""" Apply the mappings on a :py:class:`MapRecord` with
|
|
contextual options (the ``options`` given in
|
|
:py:meth:`MapRecord.values()` are accessible in
|
|
``self.options``)
|
|
|
|
:param map_record: source record to convert
|
|
:type map_record: :py:class:`MapRecord`
|
|
|
|
"""
|
|
assert self.options is not None, (
|
|
"options should be defined with '_mapping_options'")
|
|
_logger.debug('converting record %s to model %s',
|
|
map_record.source, self.model)
|
|
|
|
fields = self.options.fields
|
|
for_create = self.options.for_create
|
|
result = {}
|
|
for from_attr, to_attr in self.direct:
|
|
if isinstance(from_attr, collections.Callable):
|
|
attr_name = self._direct_source_field_name(from_attr)
|
|
else:
|
|
attr_name = from_attr
|
|
|
|
if (not fields or attr_name in fields):
|
|
value = self._map_direct(map_record.source,
|
|
from_attr,
|
|
to_attr)
|
|
result[to_attr] = value
|
|
|
|
for meth, definition in self.map_methods:
|
|
mapping_changed_by = definition.changed_by
|
|
if (not fields or not mapping_changed_by or
|
|
mapping_changed_by.intersection(fields)):
|
|
if definition.only_create and not for_create:
|
|
continue
|
|
values = meth(map_record.source)
|
|
if not values:
|
|
continue
|
|
if not isinstance(values, dict):
|
|
raise ValueError('%s: invalid return value for the '
|
|
'mapping method %s' % (values, meth))
|
|
result.update(values)
|
|
|
|
for from_attr, to_attr, model_name in self.children:
|
|
if (not fields or from_attr in fields):
|
|
result[to_attr] = self._map_child(map_record, from_attr,
|
|
to_attr, model_name)
|
|
|
|
return self.finalize(map_record, result)
|
|
|
|
def finalize(self, map_record, values):
|
|
""" Called at the end of the mapping.
|
|
|
|
Can be used to modify the values generated by all the mappings before
|
|
returning them.
|
|
|
|
:param map_record: source map_record
|
|
:type map_record: :py:class:`MapRecord`
|
|
:param values: mapped values
|
|
:returns: mapped values
|
|
:rtype: dict
|
|
"""
|
|
return values
|
|
|
|
|
|
class ImportMapper(AbstractComponent):
|
|
""" :py:class:`Mapper` for imports.
|
|
|
|
Transform a record from a backend to an Odoo record
|
|
|
|
"""
|
|
|
|
_name = 'base.import.mapper'
|
|
_inherit = 'base.mapper'
|
|
_usage = 'import.mapper'
|
|
|
|
_map_child_usage = 'import.map.child'
|
|
_map_child_fallback = 'base.map.child.import'
|
|
|
|
def _map_direct(self, record, from_attr, to_attr):
|
|
""" Apply the ``direct`` mappings.
|
|
|
|
:param record: record to convert from a source to a target
|
|
:param from_attr: name of the source attribute or a callable
|
|
:type from_attr: callable | str
|
|
:param to_attr: name of the target attribute
|
|
:type to_attr: str
|
|
"""
|
|
if isinstance(from_attr, collections.Callable):
|
|
return from_attr(self, record, to_attr)
|
|
|
|
value = record.get(from_attr)
|
|
if not value:
|
|
return False
|
|
|
|
# Backward compatibility: when a field is a relation, and a modifier is
|
|
# not used, we assume that the relation model is a binding.
|
|
# Use an explicit modifier external_to_m2o in the 'direct' mappings to
|
|
# change that.
|
|
field = self.model._fields[to_attr]
|
|
if field.type == 'many2one':
|
|
mapping_func = external_to_m2o(from_attr)
|
|
value = mapping_func(self, record, to_attr)
|
|
return value
|
|
|
|
|
|
class ExportMapper(AbstractComponent):
|
|
""" :py:class:`Mapper` for exports.
|
|
|
|
Transform a record from Odoo to a backend record
|
|
|
|
"""
|
|
|
|
_name = 'base.export.mapper'
|
|
_inherit = 'base.mapper'
|
|
_usage = 'export.mapper'
|
|
|
|
_map_child_usage = 'export.map.child'
|
|
_map_child_fallback = 'base.map.child.export'
|
|
|
|
def _map_direct(self, record, from_attr, to_attr):
|
|
""" Apply the ``direct`` mappings.
|
|
|
|
:param record: record to convert from a source to a target
|
|
:param from_attr: name of the source attribute or a callable
|
|
:type from_attr: callable | str
|
|
:param to_attr: name of the target attribute
|
|
:type to_attr: str
|
|
"""
|
|
if isinstance(from_attr, collections.Callable):
|
|
return from_attr(self, record, to_attr)
|
|
|
|
value = record[from_attr]
|
|
if not value:
|
|
return False
|
|
|
|
# Backward compatibility: when a field is a relation, and a modifier is
|
|
# not used, we assume that the relation model is a binding.
|
|
# Use an explicit modifier m2o_to_external in the 'direct' mappings to
|
|
# change that.
|
|
field = self.model._fields[from_attr]
|
|
if field.type == 'many2one':
|
|
mapping_func = m2o_to_external(from_attr)
|
|
value = mapping_func(self, record, to_attr)
|
|
return value
|
|
|
|
|
|
class MapRecord(object):
|
|
""" A record prepared to be converted using a :py:class:`Mapper`.
|
|
|
|
MapRecord instances are prepared by :py:meth:`Mapper.map_record`.
|
|
|
|
Usage::
|
|
|
|
>>> map_record = mapper.map_record(record)
|
|
>>> output_values = map_record.values()
|
|
|
|
See :py:meth:`values` for more information on the available arguments.
|
|
|
|
"""
|
|
|
|
def __init__(self, mapper, source, parent=None):
|
|
self._source = source
|
|
self._mapper = mapper
|
|
self._parent = parent
|
|
self._forced_values = {}
|
|
|
|
@property
|
|
def source(self):
|
|
""" Source record to be converted """
|
|
return self._source
|
|
|
|
@property
|
|
def parent(self):
|
|
""" Parent record if the current record is an item """
|
|
return self._parent
|
|
|
|
def values(self, for_create=None, fields=None, **kwargs):
|
|
""" Build and returns the mapped values according to the options.
|
|
|
|
Usage::
|
|
|
|
>>> map_record = mapper.map_record(record)
|
|
>>> output_values = map_record.values()
|
|
|
|
Creation of records
|
|
When using the option ``for_create``, only the mappings decorated
|
|
with ``@only_create`` will be mapped.
|
|
|
|
::
|
|
|
|
>>> output_values = map_record.values(for_create=True)
|
|
|
|
Filter on fields
|
|
When using the ``fields`` argument, the mappings will be
|
|
filtered using either the source key in ``direct`` arguments,
|
|
either the ``changed_by`` arguments for the mapping methods.
|
|
|
|
::
|
|
|
|
>>> output_values = map_record.values(
|
|
fields=['name', 'street']
|
|
)
|
|
|
|
Custom options
|
|
Arbitrary key and values can be defined in the ``kwargs``
|
|
arguments. They can later be used in the mapping methods
|
|
using ``self.options``.
|
|
|
|
::
|
|
|
|
>>> output_values = map_record.values(tax_include=True)
|
|
|
|
:param for_create: specify if only the mappings for creation
|
|
(``@only_create``) should be mapped.
|
|
:type for_create: boolean
|
|
:param fields: filter on fields
|
|
:type fields: list
|
|
:param ``**kwargs``: custom options, they can later be used in the
|
|
mapping methods
|
|
|
|
"""
|
|
options = MapOptions(for_create=for_create, fields=fields, **kwargs)
|
|
values = self._mapper._apply(self, options=options)
|
|
values.update(self._forced_values)
|
|
return values
|
|
|
|
def update(self, *args, **kwargs):
|
|
""" Force values to be applied after a mapping.
|
|
|
|
Usage::
|
|
|
|
>>> map_record = mapper.map_record(record)
|
|
>>> map_record.update(a=1)
|
|
>>> output_values = map_record.values()
|
|
# output_values will at least contain {'a': 1}
|
|
|
|
The values assigned with ``update()`` are in any case applied,
|
|
they have a greater priority than the mapping values.
|
|
|
|
"""
|
|
self._forced_values.update(*args, **kwargs)
|
|
|
|
|
|
class MapOptions(dict):
|
|
""" Container for the options of mappings.
|
|
|
|
Options can be accessed using attributes of the instance. When an
|
|
option is accessed and does not exist, it returns None.
|
|
|
|
"""
|
|
|
|
def __getitem__(self, key):
|
|
try:
|
|
return super(MapOptions, self).__getitem__(key)
|
|
except KeyError:
|
|
return None
|
|
|
|
def __getattr__(self, key):
|
|
return self[key]
|
|
|
|
def __setattr__(self, key, value):
|
|
self[key] = value
|