Initial commit: OCA Technical packages (595 packages)

This commit is contained in:
Ernad Husremovic 2025-08-29 15:43:03 +02:00
commit 2cc02aac6e
24950 changed files with 2318079 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,
)

View file

@ -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"
)

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"