mirror of
https://github.com/bringout/oca-technical.git
synced 2026-04-20 21:12:01 +02:00
Initial commit: OCA Technical packages (595 packages)
This commit is contained in:
commit
2cc02aac6e
24950 changed files with 2318079 additions and 0 deletions
|
|
@ -0,0 +1,7 @@
|
|||
from . import core
|
||||
from . import backend_adapter
|
||||
from . import binder
|
||||
from . import mapper
|
||||
from . import listener
|
||||
from . import locker
|
||||
from . import synchronizer
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
# Copyright 2013 Camptocamp SA
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
|
||||
|
||||
"""
|
||||
|
||||
Backend Adapter
|
||||
===============
|
||||
|
||||
An external adapter has a common interface to speak with the backend.
|
||||
It translates the basic orders (search, read, write) to the protocol
|
||||
used by the backend.
|
||||
|
||||
"""
|
||||
|
||||
from odoo.addons.component.core import AbstractComponent
|
||||
|
||||
|
||||
class BackendAdapter(AbstractComponent):
|
||||
"""Base Backend Adapter for the connectors"""
|
||||
|
||||
_name = "base.backend.adapter"
|
||||
_inherit = "base.connector"
|
||||
_usage = "backend.adapter"
|
||||
|
||||
|
||||
# pylint: disable=W8106
|
||||
class CRUDAdapter(AbstractComponent):
|
||||
"""Base External Adapter specialized in the handling
|
||||
of records on external systems.
|
||||
|
||||
This is an empty shell, Components can inherit and implement their own
|
||||
implementation for the methods.
|
||||
|
||||
"""
|
||||
|
||||
_name = "base.backend.adapter.crud"
|
||||
_inherit = "base.backend.adapter"
|
||||
_usage = "backend.adapter"
|
||||
|
||||
def search(self, *args, **kwargs):
|
||||
"""Search records according to some criterias
|
||||
and returns a list of ids"""
|
||||
raise NotImplementedError
|
||||
|
||||
def read(self, *args, **kwargs):
|
||||
"""Returns the information of a record"""
|
||||
raise NotImplementedError
|
||||
|
||||
def search_read(self, *args, **kwargs):
|
||||
"""Search records according to some criterias
|
||||
and returns their information"""
|
||||
raise NotImplementedError
|
||||
|
||||
def create(self, *args, **kwargs):
|
||||
"""Create a record on the external system"""
|
||||
raise NotImplementedError
|
||||
|
||||
def write(self, *args, **kwargs):
|
||||
"""Update records on the external system"""
|
||||
raise NotImplementedError
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Delete a record on the external system"""
|
||||
raise NotImplementedError
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
# Copyright 2013 Camptocamp SA
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
|
||||
|
||||
"""
|
||||
Binders
|
||||
=======
|
||||
|
||||
Binders are components that know how to find the external ID for an
|
||||
Odoo ID, how to find the Odoo ID for an external ID and how to
|
||||
create the binding between them.
|
||||
|
||||
"""
|
||||
|
||||
from odoo import fields, models, tools
|
||||
|
||||
from odoo.addons.component.core import AbstractComponent
|
||||
|
||||
|
||||
class Binder(AbstractComponent):
|
||||
"""For one record of a model, capable to find an external or
|
||||
internal id, or create the binding (link) between them
|
||||
|
||||
This is a default implementation that can be inherited or reimplemented
|
||||
in the connectors.
|
||||
|
||||
This implementation assumes that binding models are ``_inherits`` of
|
||||
the models they are binding.
|
||||
|
||||
"""
|
||||
|
||||
_name = "base.binder"
|
||||
_inherit = "base.connector"
|
||||
_usage = "binder"
|
||||
|
||||
_external_field = "external_id" # override in sub-classes
|
||||
_backend_field = "backend_id" # override in sub-classes
|
||||
_odoo_field = "odoo_id" # override in sub-classes
|
||||
_sync_date_field = "sync_date" # override in sub-classes
|
||||
|
||||
def to_internal(self, external_id, unwrap=False):
|
||||
"""Give the Odoo recordset for an external ID
|
||||
|
||||
:param external_id: external ID for which we want
|
||||
the Odoo ID
|
||||
:param unwrap: if True, returns the normal record
|
||||
else return the binding record
|
||||
:return: a recordset, depending on the value of unwrap,
|
||||
or an empty recordset if the external_id is not mapped
|
||||
:rtype: recordset
|
||||
"""
|
||||
context = self.env.context
|
||||
bindings = self.model.with_context(active_test=False).search(
|
||||
[
|
||||
(self._external_field, "=", tools.ustr(external_id)),
|
||||
(self._backend_field, "=", self.backend_record.id),
|
||||
]
|
||||
)
|
||||
if not bindings:
|
||||
if unwrap:
|
||||
return self.model.browse()[self._odoo_field]
|
||||
return self.model.browse()
|
||||
bindings.ensure_one()
|
||||
if unwrap:
|
||||
bindings = bindings[self._odoo_field]
|
||||
bindings = bindings.with_context(**context)
|
||||
return bindings
|
||||
|
||||
def to_external(self, binding, wrap=False):
|
||||
"""Give the external ID for an Odoo binding ID
|
||||
|
||||
:param binding: Odoo binding for which we want the external id
|
||||
:param wrap: if True, binding is a normal record, the
|
||||
method will search the corresponding binding and return
|
||||
the external id of the binding
|
||||
:return: external ID of the record
|
||||
"""
|
||||
if isinstance(binding, models.BaseModel):
|
||||
binding.ensure_one()
|
||||
else:
|
||||
binding = self.model.browse(binding)
|
||||
if wrap:
|
||||
binding = self.model.with_context(active_test=False).search(
|
||||
[
|
||||
(self._odoo_field, "=", binding.id),
|
||||
(self._backend_field, "=", self.backend_record.id),
|
||||
]
|
||||
)
|
||||
if not binding:
|
||||
return None
|
||||
binding.ensure_one()
|
||||
return binding[self._external_field]
|
||||
return binding[self._external_field]
|
||||
|
||||
def bind(self, external_id, binding):
|
||||
"""Create the link between an external ID and an Odoo ID
|
||||
|
||||
:param external_id: external id to bind
|
||||
:param binding: Odoo record to bind
|
||||
:type binding: int
|
||||
"""
|
||||
# Prevent False, None, or "", but not 0
|
||||
assert (
|
||||
external_id or external_id == 0
|
||||
) and binding, "external_id or binding missing, " "got: %s, %s" % (
|
||||
external_id,
|
||||
binding,
|
||||
)
|
||||
# avoid to trigger the export when we modify the `external_id`
|
||||
now_fmt = fields.Datetime.now()
|
||||
if isinstance(binding, models.BaseModel):
|
||||
binding.ensure_one()
|
||||
else:
|
||||
binding = self.model.browse(binding)
|
||||
binding.with_context(connector_no_export=True).write(
|
||||
{
|
||||
self._external_field: tools.ustr(external_id),
|
||||
self._sync_date_field: now_fmt,
|
||||
}
|
||||
)
|
||||
|
||||
def unwrap_binding(self, binding):
|
||||
"""For a binding record, gives the normal record.
|
||||
|
||||
Example: when called with a ``magento.product.product`` id,
|
||||
it will return the corresponding ``product.product`` id.
|
||||
|
||||
:param browse: when True, returns a browse_record instance
|
||||
rather than an ID
|
||||
"""
|
||||
if isinstance(binding, models.BaseModel):
|
||||
binding.ensure_one()
|
||||
else:
|
||||
binding = self.model.browse(binding)
|
||||
|
||||
return binding[self._odoo_field]
|
||||
|
||||
def unwrap_model(self):
|
||||
"""For a binding model, gives the normal model.
|
||||
|
||||
Example: when called on a binder for ``magento.product.product``,
|
||||
it will return ``product.product``.
|
||||
"""
|
||||
try:
|
||||
column = self.model._fields[self._odoo_field]
|
||||
except KeyError as err:
|
||||
raise ValueError(
|
||||
"Cannot unwrap model %s, because it has no %s fields"
|
||||
% (self.model._name, self._odoo_field)
|
||||
) from err
|
||||
return column.comodel_name
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
# Copyright 2017 Camptocamp SA
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
|
||||
|
||||
"""
|
||||
|
||||
Base Component
|
||||
==============
|
||||
|
||||
The connector proposes a 'base' Component, which can be used in
|
||||
the ``_inherit`` of your own components. This is not a
|
||||
requirement. It is already inherited by every component
|
||||
provided by the Connector.
|
||||
|
||||
Components are organized according to different usages. The connector suggests
|
||||
5 main kinds of Components. Each might have a few different usages. You can be
|
||||
as creative as you want when it comes to creating new ones though.
|
||||
|
||||
One "usage" is responsible for a specific work, and alongside with the
|
||||
collection (the backend) and the model, the usage will be used to find the
|
||||
needed component for a task.
|
||||
|
||||
Some of the Components have an implementation in the ``Connector`` addon, but
|
||||
some are empty shells that need to be implemented in the different connectors.
|
||||
|
||||
The usual categories are:
|
||||
|
||||
:py:class:`~connector.components.binder.Binder`
|
||||
The ``binders`` give the external ID or Odoo ID from respectively an
|
||||
Odoo ID or an external ID. A default implementation is available.
|
||||
|
||||
Most common usages:
|
||||
|
||||
* ``binder``
|
||||
|
||||
:py:class:`~connector.components.mapper.Mapper`
|
||||
The ``mappers`` transform a external record into an Odoo record or
|
||||
conversely.
|
||||
|
||||
Most common usages:
|
||||
|
||||
* ``import.mapper``
|
||||
* ``export.mapper``
|
||||
|
||||
:py:class:`~connector.components.backend_adapter.BackendAdapter`
|
||||
The ``backend.adapters`` implements the discussion with the ``backend's``
|
||||
APIs. They usually adapt their APIs to a common interface (CRUD).
|
||||
|
||||
Most common usages:
|
||||
|
||||
* ``backend.adapter``
|
||||
|
||||
:py:class:`~connector.components.synchronizer.Synchronizer`
|
||||
A ``synchronizer`` is the main piece of a synchronization. It
|
||||
orchestrates the flow of a synchronization and use the other
|
||||
Components
|
||||
|
||||
Most common usages:
|
||||
|
||||
* ``record.importer``
|
||||
* ``record.exporter``
|
||||
* ``batch.importer``
|
||||
* ``batch.exporter``
|
||||
|
||||
The possibilities for components do not stop there, look at the
|
||||
:class:`~connector.components.locker.RecordLocker` for an example of
|
||||
single-purpose, decoupled component.
|
||||
|
||||
|
||||
"""
|
||||
|
||||
from odoo.addons.component.core import AbstractComponent
|
||||
from odoo.addons.queue_job.exception import RetryableJobError
|
||||
|
||||
from ..database import pg_try_advisory_lock
|
||||
|
||||
|
||||
class BaseConnectorComponent(AbstractComponent):
|
||||
"""Base component for the connector
|
||||
|
||||
Is inherited by every components of the Connector (Binder, Mapper, ...)
|
||||
and adds a few methods which are of common usage in the connectors.
|
||||
|
||||
"""
|
||||
|
||||
_name = "base.connector"
|
||||
|
||||
@property
|
||||
def backend_record(self):
|
||||
"""Backend record we are working with"""
|
||||
# backward compatibility
|
||||
return self.work.collection
|
||||
|
||||
def binder_for(self, model=None):
|
||||
"""Shortcut to get Binder for a model
|
||||
|
||||
Equivalent to: ``self.component(usage='binder', model_name='xxx')``
|
||||
|
||||
"""
|
||||
return self.component(usage="binder", model_name=model)
|
||||
|
||||
def advisory_lock_or_retry(self, lock, retry_seconds=1):
|
||||
"""Acquire a Postgres transactional advisory lock or retry job
|
||||
|
||||
When the lock cannot be acquired, it raises a
|
||||
:exc:`odoo.addons.queue_job.exception.RetryableJobError` so the job
|
||||
is retried after n ``retry_seconds``.
|
||||
|
||||
Usage example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
lock_name = 'import_record({}, {}, {}, {})'.format(
|
||||
self.backend_record._name,
|
||||
self.backend_record.id,
|
||||
self.model._name,
|
||||
self.external_id,
|
||||
)
|
||||
self.advisory_lock_or_retry(lock_name, retry_seconds=2)
|
||||
|
||||
See :func:`odoo.addons.connector.connector.pg_try_advisory_lock` for
|
||||
details.
|
||||
|
||||
:param lock: The lock name. Can be anything convertible to a
|
||||
string. It needs to represent what should not be synchronized
|
||||
concurrently, usually the string will contain at least: the
|
||||
action, the backend name, the backend id, the model name, the
|
||||
external id
|
||||
:param retry_seconds: number of seconds after which a job should
|
||||
be retried when the lock cannot be acquired.
|
||||
"""
|
||||
if not pg_try_advisory_lock(self.env, lock):
|
||||
raise RetryableJobError(
|
||||
"Could not acquire advisory lock",
|
||||
seconds=retry_seconds,
|
||||
ignore_retry=True,
|
||||
)
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# Copyright 2013 Camptocamp SA
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
|
||||
|
||||
"""
|
||||
Listeners
|
||||
=========
|
||||
|
||||
Listeners are Components notified when events happen.
|
||||
Documentation in :mod:`odoo.addons.component_event.components.event`
|
||||
|
||||
The base listener for the connectors add a method
|
||||
:meth:`ConnectorListener.no_connector_export` which can be used with
|
||||
:func:`odoo.addons.component_event.skip_if`.
|
||||
|
||||
|
||||
"""
|
||||
|
||||
from odoo.addons.component.core import AbstractComponent
|
||||
|
||||
|
||||
class ConnectorListener(AbstractComponent):
|
||||
"""Base Backend Adapter for the connectors"""
|
||||
|
||||
_name = "base.connector.listener"
|
||||
_inherit = ["base.connector", "base.event.listener"]
|
||||
|
||||
def no_connector_export(self, record):
|
||||
"""Return if the 'connector_no_export' has been set in context
|
||||
|
||||
To be used with :func:`odoo.addons.component_event.skip_if`
|
||||
on Events::
|
||||
|
||||
from odoo.addons.component.core import Component
|
||||
from odoo.addons.component_event import skip_if
|
||||
|
||||
|
||||
class MyEventListener(Component):
|
||||
_name = 'my.event.listener'
|
||||
_inherit = 'base.connector.event.listener'
|
||||
_apply_on = ['magento.res.partner']
|
||||
|
||||
@skip_if(lambda: self, record, *args, **kwargs:
|
||||
self.no_connector_export(record))
|
||||
def on_record_write(self, record, fields=None):
|
||||
record.with_delay().export_record()
|
||||
|
||||
"""
|
||||
return record.env.context.get("no_connector_export") or record.env.context.get(
|
||||
"connector_no_export"
|
||||
)
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
# Copyright 2018 Camptocamp SA
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
|
||||
|
||||
import logging
|
||||
|
||||
import psycopg2
|
||||
|
||||
from odoo.addons.component.core import Component
|
||||
|
||||
from ..exception import RetryableJobError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecordLocker(Component):
|
||||
"""Component allowing to lock record(s) for the current transaction
|
||||
|
||||
Example of usage::
|
||||
|
||||
self.component('record.locker').lock(self.records)
|
||||
|
||||
See the definition of :meth:`~lock` for details.
|
||||
"""
|
||||
|
||||
_name = "base.record.locker"
|
||||
_inherit = ["base.connector"]
|
||||
_usage = "record.locker"
|
||||
|
||||
def lock(self, records, seconds=None, ignore_retry=True):
|
||||
"""Lock the records.
|
||||
|
||||
Lock the record so we are sure that only one job is running for this
|
||||
record(s) if concurrent jobs have to run a job for the same record(s).
|
||||
When concurrent jobs try to work on the same record(s), the first one
|
||||
will lock and proceed, the others will fail to acquire it and will be
|
||||
retried later
|
||||
(:exc:`~odoo.addons.queue_job.exception.RetryableJobError` is raised).
|
||||
|
||||
The lock is using a ``FOR UPDATE NOWAIT`` so any concurrent transaction
|
||||
trying FOR UPDATE/UPDATE will be rejected until the current transaction
|
||||
is committed or rollbacked.
|
||||
|
||||
A classical use case for this is to prevent concurrent exports.
|
||||
|
||||
The following parameters are forwarded to the exception
|
||||
:exc:`~odoo.addons.queue_job.exception.RetryableJobError`
|
||||
|
||||
:param seconds: In case of retry because the lock cannot be acquired,
|
||||
in how many seconds it must be retried. If not set,
|
||||
the queue_job configuration is used.
|
||||
:param ignore_retry: If True, the retry counter of the job will not be
|
||||
increased.
|
||||
"""
|
||||
sql = "SELECT id FROM %s WHERE ID IN %%s FOR UPDATE NOWAIT" % self.model._table
|
||||
try:
|
||||
self.env.cr.execute(sql, (tuple(records.ids),), log_exceptions=False)
|
||||
except psycopg2.OperationalError as err:
|
||||
_logger.info(
|
||||
"A concurrent job is already working on the same "
|
||||
"record (%s with one id in %s). Job delayed later.",
|
||||
self.model._name,
|
||||
tuple(records.ids),
|
||||
)
|
||||
raise RetryableJobError(
|
||||
"A concurrent job is already working on the same record "
|
||||
"(%s with one id in %s). The job will be retried later."
|
||||
% (self.model._name, tuple(records.ids)),
|
||||
seconds=seconds,
|
||||
ignore_retry=ignore_retry,
|
||||
) from err
|
||||
1054
odoo-bringout-oca-connector-connector/connector/components/mapper.py
Normal file
1054
odoo-bringout-oca-connector-connector/connector/components/mapper.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,446 @@
|
|||
# Copyright 2013 Camptocamp SA
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
|
||||
|
||||
"""
|
||||
|
||||
Synchronizer
|
||||
============
|
||||
|
||||
A synchronizer orchestrates a synchronization with a backend. It's the actor
|
||||
who runs the flow and glues the logic of an import or export (or else).
|
||||
It uses other components for specialized tasks.
|
||||
|
||||
For instance, it will use the mappings to convert the data between both
|
||||
systems, the backend adapters to read or write data on the backend and the
|
||||
binders to create the link between them.
|
||||
|
||||
"""
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
|
||||
import psycopg2
|
||||
|
||||
import odoo
|
||||
from odoo import _
|
||||
|
||||
from odoo.addons.component.core import AbstractComponent
|
||||
|
||||
from ..exception import IDMissingInBackend, RetryableJobError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Synchronizer(AbstractComponent):
|
||||
"""Base class for synchronizers"""
|
||||
|
||||
_name = "base.synchronizer"
|
||||
_inherit = "base.connector"
|
||||
|
||||
#: usage of the component used as mapper, can be customized in sub-classes
|
||||
_base_mapper_usage = "mapper"
|
||||
#: usage of the component used as backend adapter,
|
||||
#: can be customized in sub-classes
|
||||
_base_backend_adapter_usage = "backend.adapter"
|
||||
|
||||
def __init__(self, work_context):
|
||||
super(Synchronizer, self).__init__(work_context)
|
||||
self._backend_adapter = None
|
||||
self._binder = None
|
||||
self._mapper = None
|
||||
|
||||
def run(self):
|
||||
"""Run the synchronization"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def mapper(self):
|
||||
"""Return an instance of ``Mapper`` for the synchronization.
|
||||
|
||||
The instantiation is delayed because some synchronizations do
|
||||
not need such an unit and the unit may not exist.
|
||||
|
||||
It looks for a Component with ``_usage`` being equal to
|
||||
``_base_mapper_usage``.
|
||||
|
||||
:rtype: :py:class:`odoo.addons.component.core.Component`
|
||||
"""
|
||||
if self._mapper is None:
|
||||
self._mapper = self.component(usage=self._base_mapper_usage)
|
||||
return self._mapper
|
||||
|
||||
@property
|
||||
def binder(self):
|
||||
"""Return an instance of ``Binder`` for the synchronization.
|
||||
|
||||
The instantiations is delayed because some synchronizations do
|
||||
not need such an unit and the unit may not exist.
|
||||
|
||||
:rtype: :py:class:`odoo.addons.component.core.Component`
|
||||
"""
|
||||
if self._binder is None:
|
||||
self._binder = self.binder_for()
|
||||
return self._binder
|
||||
|
||||
@property
|
||||
def backend_adapter(self):
|
||||
"""Return an instance of ``BackendAdapter`` for the
|
||||
synchronization.
|
||||
|
||||
The instantiations is delayed because some synchronizations do
|
||||
not need such an unit and the unit may not exist.
|
||||
|
||||
It looks for a Component with ``_usage`` being equal to
|
||||
``_base_backend_adapter_usage``.
|
||||
|
||||
:rtype: :py:class:`odoo.addons.component.core.Component`
|
||||
"""
|
||||
if self._backend_adapter is None:
|
||||
self._backend_adapter = self.component(
|
||||
usage=self._base_backend_adapter_usage
|
||||
)
|
||||
return self._backend_adapter
|
||||
|
||||
|
||||
class Exporter(AbstractComponent):
|
||||
"""Synchronizer for exporting data from Odoo to a backend"""
|
||||
|
||||
_name = "base.exporter"
|
||||
_inherit = "base.synchronizer"
|
||||
_usage = "exporter"
|
||||
#: usage of the component used as mapper, can be customized in sub-classes
|
||||
_base_mapper_usage = "export.mapper"
|
||||
|
||||
|
||||
class GenericExporter(AbstractComponent):
|
||||
"""Generic Synchronizer for exporting data from Odoo to a backend"""
|
||||
|
||||
_name = "generic.exporter"
|
||||
_inherit = "base.exporter"
|
||||
_default_binding_field = None
|
||||
|
||||
def __init__(self, working_context):
|
||||
super(GenericExporter, self).__init__(working_context)
|
||||
self.binding = None
|
||||
self.external_id = None
|
||||
|
||||
def _should_import(self):
|
||||
return False
|
||||
|
||||
def _delay_import(self):
|
||||
"""Schedule an import of the record.
|
||||
|
||||
Adapt in the sub-classes when the model is not imported
|
||||
using ``import_record``.
|
||||
"""
|
||||
# force is True because the sync_date will be more recent
|
||||
# so the import would be skipped
|
||||
assert self.external_id
|
||||
self.binding.with_delay().import_record(
|
||||
self.backend_record, self.external_id, force=True
|
||||
)
|
||||
|
||||
def run(self, binding, *args, **kwargs):
|
||||
"""Run the synchronization
|
||||
|
||||
:param binding: binding record to export
|
||||
"""
|
||||
self.binding = binding
|
||||
|
||||
self.external_id = self.binder.to_external(self.binding)
|
||||
try:
|
||||
should_import = self._should_import()
|
||||
except IDMissingInBackend:
|
||||
self.external_id = None
|
||||
should_import = False
|
||||
if should_import:
|
||||
self._delay_import()
|
||||
|
||||
result = self._run(*args, **kwargs)
|
||||
|
||||
self.binder.bind(self.external_id, self.binding)
|
||||
# Commit so we keep the external ID when there are several
|
||||
# exports (due to dependencies) and one of them fails.
|
||||
# The commit will also release the lock acquired on the binding
|
||||
# record
|
||||
if not odoo.tools.config["test_enable"]:
|
||||
self.env.cr.commit() # pylint: disable=E8102
|
||||
|
||||
self._after_export()
|
||||
return result
|
||||
|
||||
def _run(self, fields=None):
|
||||
"""Flow of the synchronization, implemented in inherited classes"""
|
||||
assert self.binding
|
||||
|
||||
if not self.external_id:
|
||||
fields = None # should be created with all the fields
|
||||
|
||||
if self._has_to_skip():
|
||||
return
|
||||
|
||||
# export the missing linked resources
|
||||
self._export_dependencies()
|
||||
|
||||
# prevent other jobs to export the same record
|
||||
# will be released on commit (or rollback)
|
||||
self._lock()
|
||||
|
||||
map_record = self._map_data()
|
||||
|
||||
if self.external_id:
|
||||
record = self._update_data(map_record, fields=fields)
|
||||
if not record:
|
||||
return _("Nothing to export.")
|
||||
self._update(record)
|
||||
else:
|
||||
record = self._create_data(map_record, fields=fields)
|
||||
if not record:
|
||||
return _("Nothing to export.")
|
||||
self.external_id = self._create(record)
|
||||
return _("Record exported with ID %s on Backend.") % self.external_id
|
||||
|
||||
def _after_export(self):
|
||||
"""Can do several actions after exporting a record on the backend"""
|
||||
|
||||
def _lock(self):
|
||||
"""Lock the binding record.
|
||||
|
||||
Lock the binding record so we are sure that only one export
|
||||
job is running for this record if concurrent jobs have to export the
|
||||
same record.
|
||||
|
||||
When concurrent jobs try to export the same record, the first one
|
||||
will lock and proceed, the others will fail to lock and will be
|
||||
retried later.
|
||||
|
||||
This behavior works also when the export becomes multilevel
|
||||
with :meth:`_export_dependencies`. Each level will set its own lock
|
||||
on the binding record it has to export.
|
||||
|
||||
"""
|
||||
sql = "SELECT id FROM %s WHERE ID = %%s FOR UPDATE NOWAIT" % self.model._table
|
||||
try:
|
||||
self.env.cr.execute(sql, (self.binding.id,), log_exceptions=False)
|
||||
except psycopg2.OperationalError as err:
|
||||
_logger.info(
|
||||
"A concurrent job is already exporting the same "
|
||||
"record (%s with id %s). Job delayed later.",
|
||||
self.model._name,
|
||||
self.binding.id,
|
||||
)
|
||||
raise RetryableJobError(
|
||||
"A concurrent job is already exporting the same record "
|
||||
"(%s with id %s). The job will be retried later."
|
||||
% (self.model._name, self.binding.id)
|
||||
) from err
|
||||
|
||||
def _has_to_skip(self):
|
||||
"""Return True if the export can be skipped"""
|
||||
return False
|
||||
|
||||
@contextmanager
|
||||
def _retry_unique_violation(self):
|
||||
"""Context manager: catch Unique constraint error and retry the
|
||||
job later.
|
||||
|
||||
When we execute several jobs workers concurrently, it happens
|
||||
that 2 jobs are creating the same record at the same time (binding
|
||||
record created by :meth:`_export_dependency`), resulting in:
|
||||
|
||||
IntegrityError: duplicate key value violates unique
|
||||
constraint "my_backend_product_product_odoo_uniq"
|
||||
DETAIL: Key (backend_id, odoo_id)=(1, 4851) already exists.
|
||||
|
||||
In that case, we'll retry the import just later.
|
||||
|
||||
.. warning:: The unique constraint must be created on the
|
||||
binding record to prevent 2 bindings to be created
|
||||
for the same External record.
|
||||
|
||||
"""
|
||||
try:
|
||||
yield
|
||||
except psycopg2.IntegrityError as err:
|
||||
if err.pgcode == psycopg2.errorcodes.UNIQUE_VIOLATION:
|
||||
raise RetryableJobError(
|
||||
"A database error caused the failure of the job:\n"
|
||||
"%s\n\n"
|
||||
"Likely due to 2 concurrent jobs wanting to create "
|
||||
"the same record. The job will be retried later." % err
|
||||
) from err
|
||||
else:
|
||||
raise
|
||||
|
||||
def _export_dependency(
|
||||
self,
|
||||
relation,
|
||||
binding_model,
|
||||
component_usage="record.exporter",
|
||||
binding_field=None,
|
||||
binding_extra_vals=None,
|
||||
):
|
||||
"""
|
||||
Export a dependency. The exporter class is a subclass of
|
||||
``GenericExporter``. If a more precise class need to be defined,
|
||||
it can be passed to the ``exporter_class`` keyword argument.
|
||||
|
||||
.. warning:: a commit is done at the end of the export of each
|
||||
dependency. The reason for that is that we pushed a record
|
||||
on the backend and we absolutely have to keep its ID.
|
||||
|
||||
So you *must* take care not to modify the Odoo
|
||||
database during an export, excepted when writing
|
||||
back the external ID or eventually to store
|
||||
external data that we have to keep on this side.
|
||||
|
||||
You should call this method only at the beginning
|
||||
of the exporter synchronization,
|
||||
in :meth:`~._export_dependencies`.
|
||||
|
||||
:param relation: record to export if not already exported
|
||||
:type relation: :py:class:`odoo.models.BaseModel`
|
||||
:param binding_model: name of the binding model for the relation
|
||||
:type binding_model: str | unicode
|
||||
:param component_usage: 'usage' to look for to find the Component to
|
||||
for the export, by default 'record.exporter'
|
||||
:type exporter: str | unicode
|
||||
:param binding_field: name of the one2many field on a normal
|
||||
record that points to the binding record
|
||||
(default: my_backend_bind_ids).
|
||||
It is used only when the relation is not
|
||||
a binding but is a normal record.
|
||||
:type binding_field: str | unicode
|
||||
:binding_extra_vals: In case we want to create a new binding
|
||||
pass extra values for this binding
|
||||
:type binding_extra_vals: dict
|
||||
"""
|
||||
if binding_field is None:
|
||||
binding_field = self._default_binding_field
|
||||
if not relation:
|
||||
return
|
||||
rel_binder = self.binder_for(binding_model)
|
||||
# wrap is typically True if the relation is for instance a
|
||||
# 'product.product' record but the binding model is
|
||||
# 'my_bakend.product.product'
|
||||
wrap = relation._name != binding_model
|
||||
|
||||
if wrap and hasattr(relation, binding_field):
|
||||
domain = [
|
||||
("odoo_id", "=", relation.id),
|
||||
("backend_id", "=", self.backend_record.id),
|
||||
]
|
||||
binding = self.env[binding_model].search(domain)
|
||||
if binding:
|
||||
assert len(binding) == 1, (
|
||||
"only 1 binding for a backend is " "supported in _export_dependency"
|
||||
)
|
||||
# we are working with a unwrapped record (e.g.
|
||||
# product.category) and the binding does not exist yet.
|
||||
# Example: I created a product.product and its binding
|
||||
# my_backend.product.product and we are exporting it, but we need
|
||||
# to create the binding for the product.category on which it
|
||||
# depends.
|
||||
else:
|
||||
bind_values = {
|
||||
"backend_id": self.backend_record.id,
|
||||
"odoo_id": relation.id,
|
||||
}
|
||||
if binding_extra_vals:
|
||||
bind_values.update(binding_extra_vals)
|
||||
# If 2 jobs create it at the same time, retry
|
||||
# one later. A unique constraint (backend_id,
|
||||
# odoo_id) should exist on the binding model
|
||||
with self._retry_unique_violation():
|
||||
binding = (
|
||||
self.env[binding_model]
|
||||
.with_context(connector_no_export=True)
|
||||
.sudo()
|
||||
.create(bind_values)
|
||||
)
|
||||
# Eager commit to avoid having 2 jobs
|
||||
# exporting at the same time. The constraint
|
||||
# will pop if an other job already created
|
||||
# the same binding. It will be caught and
|
||||
# raise a RetryableJobError.
|
||||
if not odoo.tools.config["test_enable"]:
|
||||
self.env.cr.commit() # pylint: disable=E8102
|
||||
else:
|
||||
# If my_backend_bind_ids does not exist we are typically in a
|
||||
# "direct" binding (the binding record is the same record).
|
||||
# If wrap is True, relation is already a binding record.
|
||||
binding = relation
|
||||
|
||||
if not rel_binder.to_external(binding):
|
||||
exporter = self.component(usage=component_usage, model_name=binding_model)
|
||||
exporter.run(binding)
|
||||
|
||||
def _export_dependencies(self):
|
||||
"""Export the dependencies for the record"""
|
||||
return
|
||||
|
||||
def _map_data(self):
|
||||
"""Returns an instance of
|
||||
:py:class:`~odoo.addons.connector.components.mapper.MapRecord`
|
||||
|
||||
"""
|
||||
return self.mapper.map_record(self.binding)
|
||||
|
||||
def _validate_create_data(self, data):
|
||||
"""Check if the values to import are correct
|
||||
|
||||
Pro-actively check before the ``Model.create`` if some fields
|
||||
are missing or invalid
|
||||
|
||||
Raise `InvalidDataError`
|
||||
"""
|
||||
return
|
||||
|
||||
def _validate_update_data(self, data):
|
||||
"""Check if the values to import are correct
|
||||
|
||||
Pro-actively check before the ``Model.update`` if some fields
|
||||
are missing or invalid
|
||||
|
||||
Raise `InvalidDataError`
|
||||
"""
|
||||
return
|
||||
|
||||
def _create_data(self, map_record, fields=None, **kwargs):
|
||||
"""Get the data to pass to :py:meth:`_create`"""
|
||||
return map_record.values(for_create=True, fields=fields, **kwargs)
|
||||
|
||||
def _create(self, data):
|
||||
"""Create the External record"""
|
||||
# special check on data before export
|
||||
self._validate_create_data(data)
|
||||
return self.backend_adapter.create(data)
|
||||
|
||||
def _update_data(self, map_record, fields=None, **kwargs):
|
||||
"""Get the data to pass to :py:meth:`_update`"""
|
||||
return map_record.values(fields=fields, **kwargs)
|
||||
|
||||
def _update(self, data):
|
||||
"""Update an External record"""
|
||||
assert self.external_id
|
||||
# special check on data before export
|
||||
self._validate_update_data(data)
|
||||
self.backend_adapter.write(self.external_id, data)
|
||||
|
||||
|
||||
class Importer(AbstractComponent):
|
||||
"""Synchronizer for importing data from a backend to Odoo"""
|
||||
|
||||
_name = "base.importer"
|
||||
_inherit = "base.synchronizer"
|
||||
_usage = "importer"
|
||||
#: usage of the component used as mapper, can be customized in sub-classes
|
||||
_base_mapper_usage = "import.mapper"
|
||||
|
||||
|
||||
class Deleter(AbstractComponent):
|
||||
"""Synchronizer for deleting a record on the backend"""
|
||||
|
||||
_name = "base.deleter"
|
||||
_inherit = "base.synchronizer"
|
||||
#: usage of the component used as mapper, can be customized in sub-classes
|
||||
_usage = "deleter"
|
||||
Loading…
Add table
Add a link
Reference in a new issue