Repository: OCA/search-engine (language: python)

Issue:
[14.0][FIX] connector_search_engine: Register modified and sync date on bindings
CC @hparfr 


Discussion / hints:

Fix patch:
diff --git a/connector_search_engine/README.rst b/connector_search_engine/README.rst
new file mode 100644
index 00000000..f64fd0e0
--- /dev/null
+++ b/connector_search_engine/README.rst
@@ -0,0 +1,94 @@
+=======================
+Connector Search Engine
+=======================
+
+.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+   !! This file is generated by oca-gen-addon-readme !!
+   !! changes will be overwritten.                   !!
+   !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+
+.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
+    :target: https://odoo-community.org/page/development-status
+    :alt: Beta
+.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
+    :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
+    :alt: License: AGPL-3
+.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsearch--engine-lightgray.png?logo=github
+    :target: https://github.com/OCA/search-engine/tree/14.0/connector_search_engine
+    :alt: OCA/search-engine
+.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
+    :target: https://translation.odoo-community.org/projects/search-engine-14-0/search-engine-14-0-connector_search_engine
+    :alt: Translate me on Weblate
+.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
+    :target: https://runbot.odoo-community.org/runbot/276/14.0
+    :alt: Try me on Runbot
+
+|badge1| |badge2| |badge3| |badge4| |badge5| 
+
+Base module for connecting Odoo with external search engines.
+
+**Table of contents**
+
+.. contents::
+   :local:
+
+Known issues / Roadmap
+======================
+
+* Implement generic trigger for binding
+  based on ir.export linked to the index
+  (the aim is to set the binding to be updated
+  if we modify a field configured in the exporter)
+
+Changelog
+=========
+
+12.0.x.y.z (YYYY-MM-DD)
+~~~~~~~~~~~~~~~~~~~~~~~
+
+TODO
+
+Bug Tracker
+===========
+
+Bugs are tracked on `GitHub Issues <https://github.com/OCA/search-engine/issues>`_.
+In case of trouble, please check there if your issue has already been reported.
+If you spotted it first, help us smashing it by providing a detailed and welcomed
+`feedback <https://github.com/OCA/search-engine/issues/new?body=module:%20connector_search_engine%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
+
+Do not contact contributors directly about support or help with technical issues.
+
+Credits
+=======
+
+Authors
+~~~~~~~
+
+* Akretion
+* ACSONE SA/NV
+* Camptocamp
+
+Contributors
+~~~~~~~~~~~~
+
+* Sébastien BEAU <sebastien.beau@akretion.com>
+* Laurent Mignon <laurent.mignon@acsone.eu>
+* Simone Orsi <simone.orsi@camptocamp.com>
+* Raphaël Reverdy <raphael.reverdy@akretion.com>
+
+Maintainers
+~~~~~~~~~~~
+
+This module is maintained by the OCA.
+
+.. image:: https://odoo-community.org/logo.png
+   :alt: Odoo Community Association
+   :target: https://odoo-community.org
+
+OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
+This module is part of the `OCA/search-engine <https://github.com/OCA/search-engine/tree/14.0/connector_search_engine>`_ project on GitHub.
+
+You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
diff --git a/connector_search_engine/__init__.py b/connector_search_engine/__init__.py
new file mode 100644
index 00000000..f94ed854
--- /dev/null
+++ b/connector_search_engine/__init__.py
@@ -0,0 +1,6 @@
+# Copyright 2016 Akretion (http://www.akretion.com)
+# Sébastien BEAU <sebastien.beau@akretion.com>
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from . import models  # pragma: no cover
+from . import components  # pragma: no cover
diff --git a/connector_search_engine/__manifest__.py b/connector_search_engine/__manifest__.py
new file mode 100644
index 00000000..88e6e331
--- /dev/null
+++ b/connector_search_engine/__manifest__.py
@@ -0,0 +1,30 @@
+# Copyright 2016 Akretion (http://www.akretion.com)
+# Sébastien BEAU <sebastien.beau@akretion.com>
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+{
+    "name": "Connector Search Engine",
+    "version": "15.0.1.0.0",
+    "author": "Akretion,"
+    "ACSONE SA/NV,"
+    "Camptocamp,"
+    "Odoo Community Association (OCA)",
+    "website": "https://github.com/OCA/search-engine",
+    "license": "AGPL-3",
+    "category": "Generic Modules",
+    "depends": ["connector", "jsonifier", "server_environment"],
+    "external_dependencies": {"python": ["unidecode"]},
+    "data": [
+        "security/connector_search_engine_security.xml",
+        "security/se_index_config.xml",
+        "security/ir.model.access.csv",
+        "views/se_backend.xml",
+        "views/se_index.xml",
+        "views/se_index_config.xml",
+        "views/se_menu.xml",
+        "data/ir_cron.xml",
+        "data/queue_job_channel_data.xml",
+        "data/queue_job_function_data.xml",
+    ],
+    "installable": True,
+}
diff --git a/connector_search_engine/components/__init__.py b/connector_search_engine/components/__init__.py
new file mode 100644
index 00000000..ec03a7f8
--- /dev/null
+++ b/connector_search_engine/components/__init__.py
@@ -0,0 +1,9 @@
+# Copyright 2016 Akretion (http://www.akretion.com)
+# Sébastien BEAU <sebastien.beau@akretion.com>
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from . import core
+from . import exporter
+from . import deleter
+from . import adapter
+from . import mapper
diff --git a/connector_search_engine/components/adapter.py b/connector_search_engine/components/adapter.py
new file mode 100644
index 00000000..e7342281
--- /dev/null
+++ b/connector_search_engine/components/adapter.py
@@ -0,0 +1,36 @@
+# Copyright 2016 Akretion (http://www.akretion.com)
+# Sébastien BEAU <sebastien.beau@akretion.com>
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from odoo.addons.component.core import AbstractComponent
+
+
+class SeAdapter(AbstractComponent):
+    _name = "se.backend.adapter"
+    _inherit = ["base.se.connector", "base.backend.adapter"]
+    _usage = "se.backend.adapter"
+
+    @classmethod
+    def match(cls, session, model):
+        # We are a generic exporter; how cool is that?
+        return True  # pragma: no cover
+
+    def index(self, datas):
+        raise NotImplementedError()
+
+    def delete(self, binding_ids):
+        raise NotImplementedError()
+
+    def clear(self):
+        raise NotImplementedError()
+
+    def each(self):
+        raise NotImplementedError()
+
+    def settings(self, force=False):
+        raise NotImplementedError()
+
+    def external_id(self, record):
+        # Doesn't matter how the external id is stored on SE side, it should always
+        # be a valid odoo id.
+        return int(record[self._record_id_key])
diff --git a/connector_search_engine/components/core.py b/connector_search_engine/components/core.py
new file mode 100644
index 00000000..ae7bc466
--- /dev/null
+++ b/connector_search_engine/components/core.py
@@ -0,0 +1,16 @@
+# Copyright 2017 ACSONE SA/NV
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo.addons.component.core import AbstractComponent
+
+
+class BaseSeConnectorComponent(AbstractComponent):
+    _name = "base.se.connector"
+    _inherit = "base.connector"
+
+    @property
+    def _record_id_key(self):
+        return self.collection._record_id_key
+
+    def _validate_record(self, record):
+        self.collection._validate_record(record)
diff --git a/connector_search_engine/components/deleter.py b/connector_search_engine/components/deleter.py
new file mode 100644
index 00000000..65c7b9d6
--- /dev/null
+++ b/connector_search_engine/components/deleter.py
@@ -0,0 +1,26 @@
+# Copyright 2018 ACSONE SA/NV (<http://acsone.eu>)
+# Copyright 2018 Akretion (http://www.akretion.com).
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from odoo import fields
+
+from odoo.addons.component.core import Component
+
+
+class SeDeleter(Component):
+    _name = "se.deleter"
+    _inherit = ["base.se.connector", "base.deleter"]
+    _usage = "record.exporter.deleter"
+    _base_backend_adapter_usage = "se.backend.adapter"
+
+    def run(self):
+        """
+        Run the synchronization, delete the record on the backend
+        :param records: recordset
+        :return: bool
+        """
+        self.work.records.write(
+            {"sync_state": "done", "date_syncronized": fields.Datetime.now()}
+        )
+        record_ids = [x.record_id.id for x in self.work.records]
+        if record_ids:
+            return self.backend_adapter.delete(record_ids)
diff --git a/connector_search_engine/components/exporter.py b/connector_search_engine/components/exporter.py
new file mode 100644
index 00000000..4b0a7736
--- /dev/null
+++ b/connector_search_engine/components/exporter.py
@@ -0,0 +1,38 @@
+# Copyright 2016 Akretion (http://www.akretion.com)
+# Sébastien BEAU <sebastien.beau@akretion.com>
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from odoo import fields
+
+from odoo.addons.component.core import Component
+
+
+class SeExporter(Component):
+    _name = "se.exporter"
+    _inherit = ["base.se.connector", "base.exporter"]
+    _usage = "se.record.exporter"
+    _base_mapper_usage = "se.export.mapper"
+    _base_backend_adapter_usage = "se.backend.adapter"
+
+    def _index(self, records):
+        """Index the record"""
+        return self.backend_adapter.index(records)
+
+    def run(self):
+        """Run the synchronization"""
+        if self.work.records:
+            # TODO: this state should be set _after_ the indexing
+            # the best will to add an extra state "sync_running"
+            # and setting this state here.
+            # Then we create a job (with the id of the index search task)
+            # and we ask the search engine if it finish or not and
+            # when it's finish we set the binding in done
+            self.work.records.write(
+                {"sync_state": "done", "date_syncronized": fields.Datetime.now()}
+            )
+            return self._index(
+                [record.get_export_data() for record in self.work.records]
+            )
+
+    def export_settings(self, force=True):
+        """Run the settings synchronization"""
+        return self.backend_adapter.settings(force=force)
diff --git a/connector_search_engine/components/mapper.py b/connector_search_engine/components/mapper.py
new file mode 100644
index 00000000..cb30537e
--- /dev/null
+++ b/connector_search_engine/components/mapper.py
@@ -0,0 +1,24 @@
+# Copyright 2016 Akretion (http://www.akretion.com)
+# Sébastien BEAU <sebastien.beau@akretion.com>
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+
+from odoo.addons.component.core import Component
+
+
+class JsonExportMapper(Component):
+    _name = "json.export.mapper"
+    _inherit = ["base.se.connector", "base.export.mapper"]
+    _usage = "se.export.mapper"
+
+    def __init__(self, work):
+        """
+        :param work: current environment (backend, session, ...)
+        :type connector_env: :py:class:`connector.connector.Environment`
+        """
+        super(JsonExportMapper, self).__init__(work)
+        exporter = work.index.exporter_id
+        self._json_parser = exporter.get_json_parser()
+
+    def _apply(self, map_record, options=None):
+        return map_record._source.jsonify(self._json_parser)[0]
diff --git a/connector_search_engine/data/ir_cron.xml b/connector_search_engine/data/ir_cron.xml
new file mode 100644
index 00000000..2bcd5bbb
--- /dev/null
+++ b/connector_search_engine/data/ir_cron.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<odoo noupdate="1">
+    <record forcecreate="True" id="ir_cron_recompute_all_index" model="ir.cron">
+        <field name="name">Search engine: recompute all index</field>
+        <field name="active" eval="True" />
+        <field name="user_id" ref="base.user_root" />
+        <field name="interval_number">1</field>
+        <field name="interval_type">days</field>
+        <field name="numbercall">-1</field>
+        <field name="doall" eval="False" />
+        <field name="model_id" ref="model_se_index" />
+        <field name="state">code</field>
+        <field name="code">model.recompute_all_index()</field>
+    </record>
+    <record forcecreate="True" id="ir_cron_export_binding" model="ir.cron">
+        <field
+            name="name"
+        >Search engine: Generate job for exporting binding per index</field>
+        <field name="active" eval="True" />
+        <field name="user_id" ref="base.user_root" />
+        <field name="interval_number">1</field>
+        <field name="interval_type">days</field>
+        <field name="numbercall">-1</field>
+        <field name="doall" eval="False" />
+        <field name="model_id" ref="model_se_index" />
+        <field name="state">code</field>
+        <field name="code">model.generate_batch_export_per_index()</field>
+        <field name="priority" eval="6" />
+    </record>
+    <record forcecreate="True" id="ir_cron_export_settings" model="ir.cron">
+        <field name="name">Search engine: Export settings for all indexes</field>
+        <field name="active" eval="False" />
+        <field name="user_id" ref="base.user_root" />
+        <field name="interval_number">1</field>
+        <field name="interval_type">days</field>
+        <field name="numbercall">-1</field>
+        <field name="doall" eval="False" />
+        <field name="model_id" ref="model_se_index" />
+        <field name="state">code</field>
+        <field name="code">model.export_all_settings()</field>
+    </record>
+</odoo>
diff --git a/connector_search_engine/data/queue_job_channel_data.xml b/connector_search_engine/data/queue_job_channel_data.xml
new file mode 100644
index 00000000..ef32ddbe
--- /dev/null
+++ b/connector_search_engine/data/queue_job_channel_data.xml
@@ -0,0 +1,14 @@
+<odoo noupdate="1">
+    <record model="queue.job.channel" id="channel_search_engine">
+        <field name="name">search_engine</field>
+        <field name="parent_id" ref="queue_job.channel_root" />
+    </record>
+    <record model="queue.job.channel" id="channel_search_engine_recompute">
+        <field name="name">recompute</field>
+        <field name="parent_id" ref="channel_search_engine" />
+    </record>
+    <record model="queue.job.channel" id="channel_search_engine_synchronize">
+        <field name="name">synchronize</field>
+        <field name="parent_id" ref="channel_search_engine" />
+    </record>
+</odoo>
diff --git a/connector_search_engine/data/queue_job_function_data.xml b/connector_search_engine/data/queue_job_function_data.xml
new file mode 100644
index 00000000..d9beed26
--- /dev/null
+++ b/connector_search_engine/data/queue_job_function_data.xml
@@ -0,0 +1,38 @@
+<odoo noupdate="1">
+    <record
+        id="job_function_se_binding_jobify_recompute_json"
+        model="queue.job.function"
+    >
+        <field name="model_id" ref="model_se_binding" />
+        <field name="method">jobify_recompute_json</field>
+        <field name="channel_id" ref="channel_search_engine_recompute" />
+    </record>
+    <record id="job_function_se_binding_recompute_json" model="queue.job.function">
+        <field name="model_id" ref="model_se_binding" />
+        <field name="method">recompute_json</field>
+        <field name="channel_id" ref="channel_search_engine_recompute" />
+    </record>
+    <record id="job_function_se_binding_synchronize" model="queue.job.function">
+        <field name="model_id" ref="model_se_binding" />
+        <field name="method">synchronize</field>
+        <field name="channel_id" ref="channel_search_engine_synchronize" />
+    </record>
+    <record
+        id="job_function_se_binding_todelete_synchronize"
+        model="queue.job.function"
+    >
+        <field name="model_id" ref="model_se_binding_todelete" />
+        <field name="method">synchronize</field>
+        <field name="channel_id" ref="channel_search_engine_synchronize" />
+    </record>
+    <record id="job_function_se_index_batch_export" model="queue.job.function">
+        <field name="model_id" ref="model_se_index" />
+        <field name="method">batch_export</field>
+        <field name="channel_id" ref="channel_search_engine_synchronize" />
+    </record>
+    <record id="job_function_se_index_delete_obsolete_item" model="queue.job.function">
+        <field name="model_id" ref="model_se_index" />
+        <field name="method">delete_obsolete_item</field>
+        <field name="channel_id" ref="channel_search_engine_synchronize" />
+    </record>
+</odoo>
diff --git a/connector_search_engine/i18n/connector_search_engine.pot b/connector_search_engine/i18n/connector_search_engine.pot
new file mode 100644
index 00000000..b6fb0236
--- /dev/null
+++ b/connector_search_engine/i18n/connector_search_engine.pot
@@ -0,0 +1,557 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# 	* connector_search_engine
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 14.0\n"
+"Report-Msgid-Bugs-To: \n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: \n"
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__message_needaction
+msgid "Action Needed"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding__active
+msgid "Active"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__message_attachment_count
+msgid "Attachment Count"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index__backend_id
+#: model_terms:ir.ui.view,arch_db:connector_search_engine.se_index_search_view
+msgid "Backend"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.ui.menu,name:connector_search_engine.se_backend_menu
+msgid "Backends"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index__batch_size
+msgid "Batch Size"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,help:connector_search_engine.field_se_index__batch_size
+msgid "Batch size for exporting element"
+msgstr ""
+
+#. module: connector_search_engine
+#: code:addons/connector_search_engine/models/se_index.py:0
+#, python-format
+msgid "Batch task for generating %s recompute job"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding_todelete__binding_id
+msgid "Binding ID"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index__binding_todelete_ids
+msgid "Bindings to delete"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__body
+msgid "Body"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__body_str
+msgid "Body Str"
+msgstr ""
+
+#. module: connector_search_engine
+#: model_terms:ir.ui.view,arch_db:connector_search_engine.se_index_tree_view
+msgid "Clear the index in the search engine"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index__config_id
+msgid "Config"
+msgstr ""
+
+#. module: connector_search_engine
+#: model_terms:ir.ui.view,arch_db:connector_search_engine.se_backend_form_view
+msgid "Configuration"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend__create_uid
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract__create_uid
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding_todelete__create_uid
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index__create_uid
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__create_uid
+msgid "Created by"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend__create_date
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract__create_date
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding_todelete__create_date
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index__create_date
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__create_date
+msgid "Created on"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index__custom_tech_name
+msgid "Custom Tech Name"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding__data
+msgid "Data"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding__data_display
+msgid "Data Display"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding__date_modified
+msgid "Date Modified"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding__date_syncronized
+msgid "Date Syncronized"
+msgstr ""
+
+#. module: connector_search_engine
+#: code:addons/connector_search_engine/models/se_index.py:0
+#, python-format
+msgid "Delete %d obsolete records of %d for index '%s'"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend__display_name
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract__display_name
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding__display_name
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding_todelete__display_name
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index__display_name
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__display_name
+msgid "Display Name"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields.selection,name:connector_search_engine.selection__se_binding__sync_state__done
+msgid "Done"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model,name:connector_search_engine.model_se_index_config
+msgid "Elasticsearch index configuration"
+msgstr ""
+
+#. module: connector_search_engine
+#: code:addons/connector_search_engine/models/se_index.py:0
+#, python-format
+msgid "Export %d records of %d for index '%s'"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index__exporter_id
+msgid "Exporter"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__message_follower_ids
+msgid "Followers"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__message_channel_ids
+msgid "Followers (Channels)"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__message_partner_ids
+msgid "Followers (Partners)"
+msgstr ""
+
+#. module: connector_search_engine
+#: model_terms:ir.ui.view,arch_db:connector_search_engine.se_index_tree_view
+msgid "Force the export of all binding"
+msgstr ""
+
+#. module: connector_search_engine
+#: model_terms:ir.ui.view,arch_db:connector_search_engine.se_index_tree_view
+msgid ""
+"Force to recompute all binding of the index and set them to the state 'To "
+"update'"
+msgstr ""
+
+#. module: connector_search_engine
+#: model_terms:ir.ui.view,arch_db:connector_search_engine.se_index_search_view
+msgid "Group By..."
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend__id
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract__id
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding__id
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding_todelete__id
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index__id
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__id
+msgid "ID"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,help:connector_search_engine.field_se_index_config__message_needaction
+#: model:ir.model.fields,help:connector_search_engine.field_se_index_config__message_unread
+msgid "If checked, new messages require your attention."
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,help:connector_search_engine.field_se_index_config__message_has_error
+#: model:ir.model.fields,help:connector_search_engine.field_se_index_config__message_has_sms_error
+msgid "If checked, some messages have a delivery error."
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,help:connector_search_engine.field_se_binding__data_display
+msgid "Include this in debug mode to be able to inspect index data."
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend__index_ids
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract__index_ids
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding__index_id
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding_todelete__index_id
+#: model_terms:ir.ui.view,arch_db:connector_search_engine.se_backend_form_view
+#: model_terms:ir.ui.view,arch_db:connector_search_engine.se_index_tree_view
+msgid "Index"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend__index_prefix_name
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract__index_prefix_name
+msgid "Index Prefix Name"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend__index_prefix_name_env_default
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract__index_prefix_name_env_default
+msgid "Index Prefix Name Env Default"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend__index_prefix_name_env_is_editable
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract__index_prefix_name_env_is_editable
+msgid "Index Prefix Name Env Is Editable"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,help:connector_search_engine.field_se_index__config_id
+msgid "Index configuration record"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.ui.menu,name:connector_search_engine.se_index_config_menu
+msgid "Index configurations"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.ui.menu,name:connector_search_engine.se_index_menu
+msgid "Indexes"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__message_is_follower
+msgid "Is Follower"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index__lang_id
+msgid "Lang"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.constraint,message:connector_search_engine.constraint_se_index_lang_model_uniq
+msgid "Lang and model of index must be uniq per backend."
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend____last_update
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract____last_update
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding____last_update
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding_todelete____last_update
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index____last_update
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config____last_update
+msgid "Last Modified on"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend__write_uid
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract__write_uid
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding_todelete__write_uid
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index__write_uid
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__write_uid
+msgid "Last Updated by"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend__write_date
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract__write_date
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding_todelete__write_date
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index__write_date
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__write_date
+msgid "Last Updated on"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__message_main_attachment_id
+msgid "Main Attachment"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__message_has_error
+msgid "Message Delivery error"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__message_ids
+msgid "Messages"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index__model_id
+#: model_terms:ir.ui.view,arch_db:connector_search_engine.se_index_search_view
+msgid "Model"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend__name
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract__name
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index__name
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__name
+msgid "Name"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields.selection,name:connector_search_engine.selection__se_binding__sync_state__new
+msgid "New"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__message_needaction_counter
+msgid "Number of Actions"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__message_has_error_counter
+msgid "Number of errors"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,help:connector_search_engine.field_se_index_config__message_needaction_counter
+msgid "Number of messages which requires an action"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,help:connector_search_engine.field_se_index_config__message_has_error_counter
+msgid "Number of messages with delivery error"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,help:connector_search_engine.field_se_index_config__message_unread_counter
+msgid "Number of unread messages"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,help:connector_search_engine.field_se_backend__index_prefix_name
+#: model:ir.model.fields,help:connector_search_engine.field_se_backend_spec_abstract__index_prefix_name
+msgid ""
+"Prefix for technical indexes tech name. You could use this to change index "
+"names based on current env."
+msgstr ""
+
+#. module: connector_search_engine
+#: code:addons/connector_search_engine/models/se_index.py:0
+#, python-format
+msgid "Prepare a batch export of index '%s'"
+msgstr ""
+
+#. module: connector_search_engine
+#: code:addons/connector_search_engine/models/se_binding.py:0
+#, python-format
+msgid "Recompute %s json and check if need update"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__message_has_sms_error
+msgid "SMS Delivery error"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields.selection,name:connector_search_engine.selection__se_binding__sync_state__scheduled
+msgid "Scheduled"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model,name:connector_search_engine.model_se_backend
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract__se_backend_id
+msgid "Se Backend"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.actions.act_window,name:connector_search_engine.se_index_act_window
+#: model:ir.model,name:connector_search_engine.model_se_index
+msgid "Se Index"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.actions.act_window,name:connector_search_engine.se_index_config_act_window
+msgid "Se Index Config"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model,name:connector_search_engine.model_se_backend_spec_abstract
+msgid "Se Specialized Backend"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.actions.act_window,name:connector_search_engine.se_backend_act_window
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding__se_backend_id
+msgid "Search Engine Backend"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model,name:connector_search_engine.model_se_binding
+msgid "Search Engine Binding"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model,name:connector_search_engine.model_se_binding_todelete
+msgid "Search Engine Binding To Delete"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.ui.menu,name:connector_search_engine.se_root_menu
+msgid "Search Engine Connector"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:res.groups,name:connector_search_engine.group_connector_search_engine_manager
+msgid "Search Engine Connector Manager"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.actions.server,name:connector_search_engine.ir_cron_export_settings_ir_actions_server
+#: model:ir.cron,cron_name:connector_search_engine.ir_cron_export_settings
+#: model:ir.cron,name:connector_search_engine.ir_cron_export_settings
+msgid "Search engine: Export settings for all indexes"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.actions.server,name:connector_search_engine.ir_cron_export_binding_ir_actions_server
+#: model:ir.cron,cron_name:connector_search_engine.ir_cron_export_binding
+#: model:ir.cron,name:connector_search_engine.ir_cron_export_binding
+msgid "Search engine: Generate job for exporting binding per index"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.actions.server,name:connector_search_engine.ir_cron_recompute_all_index_ir_actions_server
+#: model:ir.cron,cron_name:connector_search_engine.ir_cron_recompute_all_index
+#: model:ir.cron,name:connector_search_engine.ir_cron_recompute_all_index
+msgid "Search engine: recompute all index"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend__server_env_defaults
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract__server_env_defaults
+msgid "Server Env Defaults"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend__specific_backend
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract__specific_backend
+msgid "Specific backend"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_binding__sync_state
+msgid "Sync State"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,help:connector_search_engine.field_se_index__custom_tech_name
+msgid ""
+"Take control of index technical name. The final index name is still computed"
+" and contains in any case: backend index name prefix and language if given. "
+"If no custom name is provided, model's normalized name will be used."
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend__tech_name
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract__tech_name
+msgid "Tech Name"
+msgstr ""
+
+#. module: connector_search_engine
+#: code:addons/connector_search_engine/models/se_backend_spec_abstract.py:0
+#, python-format
+msgid ""
+"The key `%s` is missing in:\n"
+"%s"
+msgstr ""
+
+#. module: connector_search_engine
+#: code:addons/connector_search_engine/models/se_backend_spec_abstract.py:0
+#, python-format
+msgid "The record is empty"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields.selection,name:connector_search_engine.selection__se_binding__sync_state__to_be_checked
+msgid "To be checked"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields.selection,name:connector_search_engine.selection__se_binding__sync_state__to_update
+msgid "To update"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend__specific_model
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_backend_spec_abstract__specific_model
+msgid "Type"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,help:connector_search_engine.field_se_backend__tech_name
+#: model:ir.model.fields,help:connector_search_engine.field_se_backend_spec_abstract__tech_name
+msgid "Unique name for technical purposes. Eg: server env keys."
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__message_unread
+msgid "Unread Messages"
+msgstr ""
+
+#. module: connector_search_engine
+#: model:ir.model.fields,field_description:connector_search_engine.field_se_index_config__message_unread_counter
+msgid "Unread Messages Counter"
+msgstr ""
+
+#. module: connector_search_engine
+#: code:addons/connector_search_engine/models/se_binding.py:0
+#, python-format
+msgid "Validation errors"
+msgstr ""
diff --git a/connector_search_engine/models/__init__.py b/connector_search_engine/models/__init__.py
new file mode 100644
index 00000000..94e04f69
--- /dev/null
+++ b/connector_search_engine/models/__init__.py
@@ -0,0 +1,11 @@
+# Copyright 2016 Akretion (http://www.akretion.com)
+# Sébastien BEAU <sebastien.beau@akretion.com>
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+
+from . import se_backend
+from . import se_backend_spec_abstract
+from . import se_index
+from . import se_index_config
+from . import se_binding
+from . import se_binding_todelete
diff --git a/connector_search_engine/models/se_backend.py b/connector_search_engine/models/se_backend.py
new file mode 100644
index 00000000..cc3512ee
--- /dev/null
+++ b/connector_search_engine/models/se_backend.py
@@ -0,0 +1,96 @@
+# Copyright 2013 Akretion (http://www.akretion.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import api, fields, models, tools
+
+
+class SeBackend(models.Model):
+    _name = "se.backend"
+    _description = "Se Backend"
+    _inherit = [
+        "connector.backend",
+        "server.env.techname.mixin",
+        "server.env.mixin",
+    ]
+
+    name = fields.Char(required=True)
+    index_prefix_name = fields.Char(
+        help="Prefix for technical indexes tech name. "
+        "You could use this to change index names based on current env."
+    )
+    specific_model = fields.Selection(
+        string="Type", selection="_select_specific_model", required=True, readonly=True
+    )
+    index_ids = fields.One2many("se.index", "backend_id")
+    specific_backend = fields.Reference(
+        compute="_compute_specific_backend",
+        selection="_select_specific_backend",
+        readonly=True,
+    )
+
+    @property
+    def _server_env_fields(self):
+        return {"index_prefix_name": {}}
+
+    @property
+    def search_engine_name(self):
+        return self.specific_backend._search_engine_name
+
+    @api.model
+    @tools.ormcache("self")
+    def _select_specific_model(self):
+        """Retrieve available specific models via matchin prefix.
+
+        You can still override this to inject your own model
+        in case you use a 100% custom name for it.
+        """
+        models = self.env["ir.model"].search(
+            [
+                ("model", "like", "se.backend.%"),
+                ("model", "!=", "se.backend.spec.abstract"),
+            ]
+        )
+        # we check if the model exist in the env
+        # indeed during module upgrade the model may not exist yet
+        return [(x.model, x.name) for x in models if x.model in self.env]
+
+    @api.model
+    @tools.ormcache("self")
+    def _select_specific_backend(self):
+        """Retrieve available specific backends."""
+        res = []
+        for model, __ in self._select_specific_model():
+            res.extend([(model, record.name) for record in self.env[model].search([])])
+        return res
+
+    @api.depends("specific_model")
+    def _compute_specific_backend(self):
+        for specific_model in self.mapped("specific_model"):
+            recs = self.filtered(
+                lambda r, model=specific_model: r.specific_model == model
+            )
+            backends = self.env[specific_model].search(
+                [("se_backend_id", "in", recs.ids)]
+            )
+            backend_by_rec = {r.se_backend_id: r for r in backends}
+            for rec in recs:
+                spec_backend = backend_by_rec.get(rec)
+                rec.specific_backend = "{},{}".format(
+                    spec_backend._name, spec_backend.id
+                )
+
+    @api.onchange("tech_name", "index_prefix_name")
+    def _onchange_tech_name(self):
+        res = super()._onchange_tech_name()
+        if self.index_prefix_name:
+            # make sure is normalized
+            self.index_prefix_name = self._normalize_tech_name(self.index_prefix_name)
+        else:
+            self.index_prefix_name = self.tech_name
+        return res
+
+    def _handle_tech_name(self, vals):
+        res = super()._handle_tech_name(vals)
+        if not vals.get("index_prefix_name") and vals.get("tech_name"):
+            vals["index_prefix_name"] = vals["tech_name"]
+        return res
diff --git a/connector_search_engine/models/se_backend_spec_abstract.py b/connector_search_engine/models/se_backend_spec_abstract.py
new file mode 100644
index 00000000..99ef3fdd
--- /dev/null
+++ b/connector_search_engine/models/se_backend_spec_abstract.py
@@ -0,0 +1,74 @@
+# Copyright 2013 Akretion (http://www.akretion.com)
+# @author Sébestien Beau <sebastien.beau@akretion.com>
+# Copyright 2020 Camptomcap (http://www.camptomcamp.com)
+# @author Simone Orsi <simahawk@gmail.com>
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import _, api, fields, models
+
+
+class SeBackendSpecAbstract(models.AbstractModel):
+    _name = "se.backend.spec.abstract"
+    _description = "Se Specialized Backend"
+    _inherit = "connector.backend"
+
+    # declare a unique name for this search engine service
+    # (eg: elasticsearch, algolia, my-super-engine).
+    # This can be used by other modules to understand
+    # which engine they are dealing with
+    _search_engine_name = ""
+    # Unique index record key
+    _record_id_key = None
+
+    # Delegation inheritance
+    se_backend_id = fields.Many2one(
+        comodel_name="se.backend",
+        ondelete="cascade",
+        index=True,
+        auto_join=True,
+        delegate=True,
+        required=True,
+    )
+    # This must be re-defined here because
+    # we want to be able to set the prefix based on the specific search engine.
+    index_prefix_name = fields.Char(
+        related="se_backend_id.index_prefix_name",
+        readonly=False,
+    )
+
+    @property
+    def _server_env_fields(self):
+        # We need this because calling `super` in the specific backend
+        # won't call the property from `se.backend` because of `inherits` behavior.
+        return {"index_prefix_name": {}}
+
+    @api.model
+    def create(self, vals):
+        vals["specific_model"] = self._name
+        return super(SeBackendSpecAbstract, self).create(vals)
+
+    def unlink(self):
+        se_backend = self.mapped("se_backend_id")
+        res = super(SeBackendSpecAbstract, self).unlink()
+        # NOTE: this will delete indexes and bindings by cascade
+        se_backend.unlink()
+        return res
+
+    def _get_api_credentials(self):
+        # TODO: user self.name to retrieve creds from server env
+        # TODO: username password etc
+        return {}  # pragma: no cover
+
+    def _validate_record(self, record):
+        """Validate record for the specific search engine.
+
+        :param record: a dict representing a record to index
+        :return: error message if not validated, None if it's all good.
+        """
+        if not record:
+            return _("The record is empty")
+        if not record.get(self._record_id_key):
+            # Ensure _record_id_key is set when creating/updating records
+            return _("The key `%(record_id_key)s` is missing in:\n%(record)s") % (
+                {"record_id_key": self._record_id_key, "record": record}
+            )
diff --git a/connector_search_engine/models/se_binding.py b/connector_search_engine/models/se_binding.py
new file mode 100644
index 00000000..4ddd8bfe
--- /dev/null
+++ b/connector_search_engine/models/se_binding.py
@@ -0,0 +1,203 @@
+# Copyright 2013 Akretion (http://www.akretion.com)
+# Copyright 2021 Camptocamp (http://www.camptocamp.com)
+# Simone Orsi <simone.orsi@camptocamp.com>
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+import json
+import logging
+
+from odoo import _, api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+class SeBinding(models.AbstractModel):
+    _name = "se.binding"
+    _description = "Search Engine Binding"
+
+    # Tech flag to identify model for SE bindings
+    _se_model = True
+    # Tech flag to identify model for SE bindings
+    # that do not require lang specific indexes.
+    # This flag does not trigger any automatic machinery.
+    # It provides a common key to provide implementers a unified way
+    # to check whether their specific binding models need or not lang spec index.
+    _se_index_lang_agnostic = False
+
+    se_backend_id = fields.Many2one(
+        "se.backend", related="index_id.backend_id", string="Search Engine Backend"
+    )
+    index_id = fields.Many2one(
+        "se.index",
+        string="Index",
+        required=True,
+        # TODO: shall we use 'restrict' here to preserve existing data?
+        ondelete="cascade",
+    )
+    sync_state = fields.Selection(
+        [
+            ("new", "New"),
+            ("to_update", "To update"),
+            ("scheduled", "Scheduled"),
+            ("done", "Done"),
+            ("to_be_checked", "To be checked"),
+        ],
+        default="new",
+        readonly=True,
+    )
+    date_modified = fields.Datetime(readonly=True)
+    date_syncronized = fields.Datetime(readonly=True)
+    data = fields.Serialized()
+    data_display = fields.Text(
+        compute="_compute_data_display",
+        help="Include this in debug mode to be able to inspect index data.",
+    )
+    active = fields.Boolean(default=True)
+
+    @api.depends("data")
+    def _compute_data_display(self):
+        for rec in self:
+            rec.data_display = json.dumps(rec.data, sort_keys=True, indent=4)
+
+    def get_export_data(self):
+        """Public method to retrieve export data."""
+        return self.data
+
+    def _is_indexed(self):
+        self.ensure_one()
+        if self.sync_state == "new":
+            return False
+        elif self.sync_state == "done" and not self.active:
+            return False
+        return True
+
+    @api.model
+    def create(self, vals):
+        record = super(SeBinding, self).create(vals)
+        record.jobify_recompute_json()
+        return record
+
+    def write(self, vals):
+        not_new = self.browse()
+        if "active" in vals and not vals["active"]:
+            not_new = self.filtered(lambda x: x.sync_state != "new")
+            new_vals = vals.copy()
+            new_vals["sync_state"] = "to_update"
+            super(SeBinding, not_new).write(new_vals)
+
+        res = super(SeBinding, self - not_new).write(vals)
+        return res
+
+    def _prepare_todelete_vals(self):
+        self.ensure_one()
+        return {
+            "index_id": self.index_id.id,
+            "binding_id": self.id,
+        }
+
+    def unlink(self):
+        # Store unlinked binding ids in se.binding.todelete for a later processing
+        todelete = self.filtered(lambda rec: rec._is_indexed())
+        if todelete:
+            todelete_vals_list = [rec._prepare_todelete_vals() for rec in todelete]
+            self.env["se.binding.todelete"].sudo().create(todelete_vals_list)
+        return super().unlink()
+
+    def jobify_recompute_json(self, force_export=False):
+        description = _("Recompute %(name)s json and check if need update") % {
+            "name": self._name
+        }
+
+        # The job creation with tracking is very costly. So disable it.
+        for record in self.with_context(tracking_disable=True):
+            record.with_delay(description=description).recompute_json(
+                force_export=force_export
+            )
+
+    def _work_by_index(self, active=True):
+        self = self.exists()
+        for backend in self.mapped("se_backend_id"):
+            for index in self.mapped("index_id"):
+                bindings = self.filtered(
+                    lambda b, backend=backend, index=index: b.se_backend_id == backend
+                    and b.index_id == index
+                    and b.active == active
+                )
+                specific_backend = backend.specific_backend
+                with specific_backend.work_on(
+                    self._name, records=bindings, index=index
+                ) as work:
+                    yield work
+
+    # TODO maybe we need to add lock (todo check)
+    def recompute_json(self, force_export=False):
+        """Compute index record data as JSON."""
+        # `sudo` because the recomputation can be triggered from everywhere
+        # (eg: an update of a product in the stock) and is not granted
+        # that the user triggering it has access to all required records
+        # (eg: se.backend or related records needed to compute index values).
+        # All in all, this is safe because the index data should always
+        # be the same no matter the access rights of the user triggering this.
+        result = []
+        validation_errors = []
+        to_be_checked = []
+        for work in self.sudo()._work_by_index():
+            mapper = work.component(usage="se.export.mapper")
+            for binding in work.records.with_context(
+                **self._recompute_json_work_ctx(work)
+            ):
+                index_record = mapper.map_record(binding).values()
+                # Validate data and track items to check
+                error = self._validate_record(work, index_record)
+                if error:
+                    msg = "{}: {}".format(str(binding), error)
+                    _logger.error(msg)
+                    validation_errors.append(msg)
+                    to_be_checked.append(binding.id)
+                    # skip record
+                    continue
+                if binding.data != index_record or force_export:
+                    vals = {"data": index_record}
+                    if binding.sync_state != "to_update":
+                        vals["sync_state"] = "to_update"
+                        vals["date_modified"] = fields.Datetime.now()
+                    binding.write(vals)
+        if validation_errors:
+            result.append(_("Validation errors") + "\n" + "\n".join(validation_errors))
+        if to_be_checked:
+            self.browse(to_be_checked).write({"sync_state": "to_be_checked"})
+        return "\n\n".join(result)
+
+    def _recompute_json_work_ctx(self, work):
+        ctx = {}
+        if work.index.lang_id:
+            ctx["lang"] = work.index.lang_id.code
+        return ctx
+
+    def _validate_record(self, work, index_record):
+        return work.collection._validate_record(index_record)
+
+    def synchronize(self):
+        # We volontary do the export and delete in the same transaction
+        # we try first to process it into two different process but the code
+        # was more complex and it was harder to catch/understand
+        # active/inactive case for example:
+        #
+        # 1. some body bind a product and an export job is created
+        # 2. the binding is inactivated
+        # 3. when the job runs we must exclude all inactive binding
+        #
+        # Hence in both export/delete we have to re-filter all bindings
+        # using one transaction and one sync method allow to filter only once
+        # and to do the right action as we are in a transaction.
+        export_ids = []
+        delete_ids = []
+        for work in self.sudo()._work_by_index():
+            exporter = work.component(usage="se.record.exporter")
+            exporter.run()
+            export_ids += work.records.ids
+        for work in self.sudo()._work_by_index(active=False):
+            deleter = work.component(usage="record.exporter.deleter")
+            deleter.run()
+            delete_ids += work.records.ids
+        return "Exported ids : {}\nDeleted ids : {}".format(export_ids, delete_ids)
diff --git a/connector_search_engine/models/se_binding_todelete.py b/connector_search_engine/models/se_binding_todelete.py
new file mode 100644
index 00000000..5797fa7f
--- /dev/null
+++ b/connector_search_engine/models/se_binding_todelete.py
@@ -0,0 +1,38 @@
+# Copyright 2021 Camptocamp (https://www.camptocamp.com).
+# @author Iván Todorovich <ivan.todorovich@camptocamp.com>
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import fields, models
+
+
+class SeBindingToDelete(models.Model):
+    """Search Engine Binding To Delete
+
+    When bound records are deleted, their IDs are temporarily stored here
+    until the next synchronization.
+    """
+
+    _name = "se.binding.todelete"
+    _description = "Search Engine Binding To Delete"
+
+    index_id = fields.Many2one(
+        "se.index",
+        string="Index",
+        ondelete="cascade",
+        required=True,
+    )
+    binding_id = fields.Integer(
+        string="Binding ID",
+        required=True,
+    )
+
+    def synchronize(self):
+        delete_ids = []
+        for index in self.index_id:
+            todelete = self.filtered(lambda rec: rec.index_id == index)
+            binding_ids = todelete.mapped("binding_id")
+            adapter = index._get_backend_adapter()
+            adapter.delete(binding_ids)
+            delete_ids += binding_ids
+        self.unlink()
+        return f"Deleted ids : {delete_ids}"
diff --git a/connector_search_engine/models/se_index.py b/connector_search_engine/models/se_index.py
new file mode 100644
index 00000000..ed882c5f
--- /dev/null
+++ b/connector_search_engine/models/se_index.py
@@ -0,0 +1,264 @@
+# Copyright 2013 Akretion (http://www.akretion.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+import logging
+
+from odoo import _, api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+class SeIndex(models.Model):
+    _name = "se.index"
+    _description = "Se Index"
+
+    name = fields.Char(compute="_compute_name", store=True)
+    custom_tech_name = fields.Char(
+        help="Take control of index technical name. "
+        "The final index name is still computed and contains in any case: "
+        "backend index name prefix and language if given. "
+        "If no custom name is provided, model's normalized name will be used."
+    )
+    backend_id = fields.Many2one(
+        "se.backend", string="Backend", required=True, ondelete="cascade"
+    )
+    lang_id = fields.Many2one("res.lang", string="Lang", required=False)
+    model_id = fields.Many2one(
+        "ir.model",
+        string="Model",
+        required=True,
+        domain=lambda self: self._model_id_domain(),
+        ondelete="cascade",
+    )
+    exporter_id = fields.Many2one("ir.exports", string="Exporter")
+    batch_size = fields.Integer(default=5000, help="Batch size for exporting element")
+    config_id = fields.Many2one(
+        comodel_name="se.index.config",
+        string="Config",
+        help="Index configuration record",
+    )
+    binding_todelete_ids = fields.One2many(
+        comodel_name="se.binding.todelete",
+        inverse_name="index_id",
+        string="Bindings to delete",
+        readonly=True,
+    )
+
+    @api.model
+    def _model_id_domain(self):
+        se_model_names = []
+        for model in self.env:
+            if self.env[model]._abstract or self.env[model]._transient:
+                continue
+            if hasattr(self.env[model], "_se_model"):
+                se_model_names.append(model)
+        return [("model", "in", se_model_names)]
+
+    _sql_constraints = [
+        (
+            "lang_model_uniq",
+            "unique(backend_id, lang_id, model_id)",
+            "Lang and model of index must be uniq per backend.",
+        )
+    ]
+
+    @api.onchange("model_id")
+    def onchange_model_id(self):
+        self.exporter_id = False
+        if self.model_id:
+            domain = [("resource", "=", self.model_id.model)]
+            return {"domain": {"exporter_id": domain}}
+
+    @api.model
+    def recompute_all_index(self, domain=None):
+        if domain is None:
+            domain = []
+        return self.search(domain).recompute_all_binding()
+
+    def force_recompute_all_binding(self):
+        for record in self:
+            record.recompute_all_binding(force_export=True)
+
+    def recompute_all_binding(self, force_export=False, batch_size=500):
+        target_models = self.mapped("model_id.model")
+        for target_model in target_models:
+            indexes = self.filtered(lambda r, m=target_model: r.model_id.model == m)
+            bindings = self.env[target_model].search([("index_id", "in", indexes.ids)])
+            while bindings:
+                processing = bindings[0:batch_size]  # noqa: E203
+                bindings = bindings[batch_size:]  # noqa: E203
+                description = _("Batch task for generating %s recompute job") % len(
+                    processing
+                )
+                processing.with_delay(description=description).jobify_recompute_json(
+                    force_export=force_export
+                )
+        return True
+
+    @api.depends(
+        "custom_tech_name", "lang_id", "model_id", "backend_id.index_prefix_name"
+    )
+    def _compute_name(self):
+        for rec in self:
+            if not rec.backend_id:
+                # in onchange on concrete views rec is a new Id
+                # so we can't calc the right name.
+                rec.name = ""
+            else:
+                rec.name = rec._make_name()
+
+    def _make_name(self):
+        """Compute the final name of the index."""
+        name = ""
+        backend = self.backend_id
+        tech_name = self._make_tech_name()
+        if tech_name:
+            if not backend.index_prefix_name:
+                # index_prefix_name should be not empty
+                # indeed from the UI the index_prefix_name is alway fill base on
+                # tech_name
+                # So if it's empty it because we have change some config using
+                # server env or using dataencryption module
+                # in that case we set the default value to fix everything
+                backend._onchange_tech_name()
+            bits = [backend.index_prefix_name, tech_name]
+            if self.lang_id:
+                bits.append(self.lang_id.code)
+            name = "_".join(bits)
+        return name
+
+    def _make_tech_name(self):
+        """Compute the main part of the name of the index."""
+        tech_name = self.custom_tech_name or self.model_id.name or ""
+        return self.backend_id._normalize_tech_name(tech_name)
+
+    def force_batch_export(self):
+        self.ensure_one()
+        self._jobify_batch_export(force_export=True)
+
+    def _jobify_batch_export(self, force_export=False):
+        self.ensure_one()
+        description = _("Prepare a batch export of index '%s'") % self.name
+        self.with_delay(description=description).batch_export(force_export)
+
+    @api.model
+    def generate_batch_export_per_index(self, domain=None):
+        if domain is None:
+            domain = []
+        for record in self.search(domain):
+            record._jobify_batch_export()
+        return True
+
+    def _get_domain_for_exporting_binding(self, force_export=False):
+        domain = [("index_id", "=", self.id)]
+        if not force_export:
+            domain.append(("sync_state", "=", "to_update"))
+        return domain
+
+    def _batch_export(self, force_export=False):
+        self.ensure_one()
+        domain = self._get_domain_for_exporting_binding(force_export)
+        binding_obj = self.env[self.model_id.model]
+        bindings = binding_obj.with_context(active_test=False).search(domain)
+        bindings_count = len(bindings)
+        while bindings:
+            processing = bindings[0 : self.batch_size]  # noqa: E203
+            bindings = bindings[self.batch_size :]  # noqa: E203
+            description = _(
+                "Export %(len_processing)d records of %(bindings_count)d for index '%(name)s'"
+            ) % (
+                {
+                    "len_processing": len(processing),
+                    "bindings_count": bindings_count,
+                    "name": self.name,
+                }
+            )
+            processing.with_delay(description=description).synchronize()
+            processing.with_context(connector_no_export=True).write(
+                {"sync_state": "scheduled"}
+            )
+
+    def _batch_delete(self):
+        self.ensure_one()
+        binding_todelete_ids = self.binding_todelete_ids
+        binding_todelete_count = len(binding_todelete_ids)
+        while binding_todelete_ids:
+            processing = binding_todelete_ids[0 : self.batch_size]  # noqa: E203
+            binding_todelete_ids = binding_todelete_ids[self.batch_size :]  # noqa: E203
+            description = _(
+                "Delete %(len_processing)d obsolete records of "
+                "%(binding_todelete_count)d for index '%(name)s'"
+            ) % (
+                {
+                    "len_processing": len(processing),
+                    "binding_todelete_count": binding_todelete_count,
+                    "name": self.name,
+                }
+            )
+
+            processing.with_delay(description=description).synchronize()
+
+    def batch_export(self, force_export=False):
+        self.ensure_one()
+        self._batch_export(force_export=force_export)
+        self._batch_delete()
+        return True
+
+    def _get_backend_adapter(self, backend=None, model=None, index=None, **kw):
+        backend = backend or self.backend_id.specific_backend
+        model = model or self._name
+        index = index or self
+        with backend.work_on(model, index=index, **kw) as work:
+            return work.component(usage="se.backend.adapter")
+
+    def clear_index(self):
+        self.ensure_one()
+        adapter = self._get_backend_adapter()
+        adapter.clear()
+        return True
+
+    def _get_settings(self):
+        """
+        Override this method is sub modules in order to pass the adequate
+        settings (like Facetting, pagination, advanced settings, etc...)
+        """
+        self.ensure_one()
+        return {}
+
+    @api.model
+    def export_all_settings(self):
+        self.search([]).export_settings()
+
+    def export_settings(self):
+        for index in self:
+            se_specific_backend = index.backend_id.specific_backend
+            with se_specific_backend.work_on(index.model_id.model, index=index) as work:
+                exporter = work.component(usage="se.record.exporter")
+                exporter.export_settings()
+
+    def resynchronize_all_bindings(self):
+        """Force sync between Odoo records and index records.
+
+        This method will iter on all item in the index of the search engine
+        if the corresponding binding do not exist on odoo it will create a job
+        that delete all this obsolete items.
+        You should not use this method for day to day job, it only an helper
+        for recovering your index after a dammage.
+        You can also drop index but this will introduce downtime, so it's
+        better to force a resynchronization"""
+        for index in self:
+            item_ids = []
+            backend = index.backend_id.specific_backend
+            adapter = self._get_backend_adapter(backend=backend, index=index)
+            binding_model = self.env[index.model_id.model]
+            for index_record in adapter.each():
+                ext_id = adapter.external_id(index_record)
+                binding = binding_model.browse(ext_id).exists()
+                if not binding:
+                    item_ids.append(ext_id)
+            index.with_delay().delete_obsolete_item(item_ids)
+
+    def delete_obsolete_item(self, item_ids):
+        adapter = self._get_backend_adapter()
+        adapter.delete(item_ids)
+        return f"Deleted ids : {item_ids}"
diff --git a/connector_search_engine/models/se_index_config.py b/connector_search_engine/models/se_index_config.py
new file mode 100644
index 00000000..d60e87e0
--- /dev/null
+++ b/connector_search_engine/models/se_index_config.py
@@ -0,0 +1,35 @@
+# Copyright 2019 ACSONE SA/NV
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+import json
+
+from odoo import api, fields, models
+
+from odoo.addons.base_sparse_field.models.fields import Serialized
+
+
+# TODO: this part is copied from Elasticsearch but should stay in base module
+class SeIndexConfig(models.Model):
+
+    _name = "se.index.config"
+    _inherit = ["mail.thread"]
+    _description = "Elasticsearch index configuration"
+
+    name = fields.Char(required=True)
+    body = Serialized(required=True, default={})
+    # This field is used since no widget exists to edit a serialized field
+    # into the web fontend
+    body_str = fields.Text(
+        compute="_compute_body_str",
+        inverse="_inverse_body_str",
+        default="{}",
+        tracking=True,
+    )
+
+    @api.depends("body")
+    def _compute_body_str(self):
+        for rec in self:
+            rec.body_str = json.dumps(rec.body)
+
+    def _inverse_body_str(self):
+        for rec in self:
+            rec.body = json.loads(rec.body_str or "{}")
diff --git a/connector_search_engine/readme/CHANGES.rst b/connector_search_engine/readme/CHANGES.rst
new file mode 100644
index 00000000..ec3fd05c
--- /dev/null
+++ b/connector_search_engine/readme/CHANGES.rst
@@ -0,0 +1,37 @@
+Changelog
+---------
+
+Future (?)
+~~~~~~~~~~
+
+
+14.0.2.0.0
+~~~~~~~~~~~~
+
+**Breaking change**
+
+
+For historical reason (first implementation with algolia)
+- the id of the binding was added in the index
+- and for elastic/algolia the objectID (= the id of the record bound) was also added
+
+This lead to a uncomprehensible situation where the frontend dev have an "id" and an "objectID" with different value and have no idea of which one should be used for filtering
+
+Magic injection of the "id" have been removed (as we already define a export.line in shopinvader) and explicit is better then implicit.
+
+Note: in shopinvader we push in the key "id" the id of the record bound (we do not care of the id of the binding).
+
+Elastic Connector do not use any more the "objectID" key
+Algolia Connector still use the "objectID" (required) but the value is the same as the id
+
+See issue shopivader issue `#1000 <https://github.com/shopinvader/odoo-shopinvader/issues/1000>`_
+
+
+12.0.2.0.0
+~~~~~~~~~~
+
+- index field name is now a computed field based on the backend name, the model exported and the lang
+- remove dependency on keychain
+- Improve UI on search engine backend (domain on model and exporter...)
+- improve test coverage
+- use black for auto code style
diff --git a/connector_search_engine/readme/CONTRIBUTORS.rst b/connector_search_engine/readme/CONTRIBUTORS.rst
new file mode 100644
index 00000000..c286408e
--- /dev/null
+++ b/connector_search_engine/readme/CONTRIBUTORS.rst
@@ -0,0 +1,4 @@
+* Sébastien BEAU <sebastien.beau@akretion.com>
+* Laurent Mignon <laurent.mignon@acsone.eu>
+* Simone Orsi <simone.orsi@camptocamp.com>
+* Raphaël Reverdy <raphael.reverdy@akretion.com>
diff --git a/connector_search_engine/readme/DESCRIPTION.rst b/connector_search_engine/readme/DESCRIPTION.rst
new file mode 100644
index 00000000..a7738e08
--- /dev/null
+++ b/connector_search_engine/readme/DESCRIPTION.rst
@@ -0,0 +1,1 @@
+Base module for connecting Odoo with external search engines.
diff --git a/connector_search_engine/readme/HISTORY.rst b/connector_search_engine/readme/HISTORY.rst
new file mode 100644
index 00000000..e3762662
--- /dev/null
+++ b/connector_search_engine/readme/HISTORY.rst
@@ -0,0 +1,4 @@
+12.0.x.y.z (YYYY-MM-DD)
+~~~~~~~~~~~~~~~~~~~~~~~
+
+TODO
diff --git a/connector_search_engine/readme/ROADMAP.rst b/connector_search_engine/readme/ROADMAP.rst
new file mode 100644
index 00000000..94648904
--- /dev/null
+++ b/connector_search_engine/readme/ROADMAP.rst
@@ -0,0 +1,4 @@
+* Implement generic trigger for binding
+  based on ir.export linked to the index
+  (the aim is to set the binding to be updated
+  if we modify a field configured in the exporter)
diff --git a/connector_search_engine/security/connector_search_engine_security.xml b/connector_search_engine/security/connector_search_engine_security.xml
new file mode 100644
index 00000000..422e3b18
--- /dev/null
+++ b/connector_search_engine/security/connector_search_engine_security.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<odoo>
+    <record id="group_connector_search_engine_manager" model="res.groups">
+        <field name="name">Search Engine Connector Manager</field>
+        <field name="users" eval="[(4, ref('base.user_root'))]" />
+    </record>
+</odoo>
diff --git a/connector_search_engine/security/ir.model.access.csv b/connector_search_engine/security/ir.model.access.csv
new file mode 100644
index 00000000..e23234ff
--- /dev/null
+++ b/connector_search_engine/security/ir.model.access.csv
@@ -0,0 +1,4 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_se_backend,Search Engine Backend connector manager,model_se_backend,connector.group_connector_manager,1,1,1,1
+access_se_index,Search Engine Index connector manager,model_se_index,connector.group_connector_manager,1,1,1,1
+access_se_binding_todelete,Search Engine Binding To Delete,model_se_binding_todelete,connector.group_connector_manager,1,1,1,1
diff --git a/connector_search_engine/security/se_index_config.xml b/connector_search_engine/security/se_index_config.xml
new file mode 100644
index 00000000..326e4003
--- /dev/null
+++ b/connector_search_engine/security/se_index_config.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<!-- Copyright 2019 ACSONE SA/NV
+     License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
+<odoo>
+    <record model="ir.model.access" id="se_index_config_manage">
+        <field name="name">se.index.config manage</field>
+        <field name="model_id" ref="model_se_index_config" />
+        <field name="group_id" ref="connector.group_connector_manager" />
+        <field name="perm_read" eval="1" />
+        <field name="perm_create" eval="1" />
+        <field name="perm_write" eval="1" />
+        <field name="perm_unlink" eval="1" />
+    </record>
+</odoo>
diff --git a/connector_search_engine/static/description/icon.png b/connector_search_engine/static/description/icon.png
new file mode 100644
index 00000000..3a0328b5
Binary files /dev/null and b/connector_search_engine/static/description/icon.png differ
diff --git a/connector_search_engine/static/description/index.html b/connector_search_engine/static/description/index.html
new file mode 100644
index 00000000..d0c486ff
--- /dev/null
+++ b/connector_search_engine/static/description/index.html
@@ -0,0 +1,445 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+<head>
+<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" />
+<title>Connector Search Engine</title>
+<style type="text/css">
+
+/*
+:Author: David Goodger (goodger@python.org)
+:Id: $Id: html4css1.css 7952 2016-07-26 18:15:59Z milde $
+:Copyright: This stylesheet has been placed in the public domain.
+
+Default cascading style sheet for the HTML output of Docutils.
+
+See http://docutils.sf.net/docs/howto/html-stylesheets.html for how to
+customize this style sheet.
+*/
+
+/* used to remove borders from tables and images */
+.borderless, table.borderless td, table.borderless th {
+  border: 0 }
+
+table.borderless td, table.borderless th {
+  /* Override padding for "table.docutils td" with "! important".
+     The right padding separates the table cells. */
+  padding: 0 0.5em 0 0 ! important }
+
+.first {
+  /* Override more specific margin styles with "! important". */
+  margin-top: 0 ! important }
+
+.last, .with-subtitle {
+  margin-bottom: 0 ! important }
+
+.hidden {
+  display: none }
+
+.subscript {
+  vertical-align: sub;
+  font-size: smaller }
+
+.superscript {
+  vertical-align: super;
+  font-size: smaller }
+
+a.toc-backref {
+  text-decoration: none ;
+  color: black }
+
+blockquote.epigraph {
+  margin: 2em 5em ; }
+
+dl.docutils dd {
+  margin-bottom: 0.5em }
+
+object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
+  overflow: hidden;
+}
+
+/* Uncomment (and remove this text!) to get bold-faced definition list terms
+dl.docutils dt {
+  font-weight: bold }
+*/
+
+div.abstract {
+  margin: 2em 5em }
+
+div.abstract p.topic-title {
+  font-weight: bold ;
+  text-align: center }
+
+div.admonition, div.attention, div.caution, div.danger, div.error,
+div.hint, div.important, div.note, div.tip, div.warning {
+  margin: 2em ;
+  border: medium outset ;
+  padding: 1em }
+
+div.admonition p.admonition-title, div.hint p.admonition-title,
+div.important p.admonition-title, div.note p.admonition-title,
+div.tip p.admonition-title {
+  font-weight: bold ;
+  font-family: sans-serif }
+
+div.attention p.admonition-title, div.caution p.admonition-title,
+div.danger p.admonition-title, div.error p.admonition-title,
+div.warning p.admonition-title, .code .error {
+  color: red ;
+  font-weight: bold ;
+  font-family: sans-serif }
+
+/* Uncomment (and remove this text!) to get reduced vertical space in
+   compound paragraphs.
+div.compound .compound-first, div.compound .compound-middle {
+  margin-bottom: 0.5em }
+
+div.compound .compound-last, div.compound .compound-middle {
+  margin-top: 0.5em }
+*/
+
+div.dedication {
+  margin: 2em 5em ;
+  text-align: center ;
+  font-style: italic }
+
+div.dedication p.topic-title {
+  font-weight: bold ;
+  font-style: normal }
+
+div.figure {
+  margin-left: 2em ;
+  margin-right: 2em }
+
+div.footer, div.header {
+  clear: both;
+  font-size: smaller }
+
+div.line-block {
+  display: block ;
+  margin-top: 1em ;
+  margin-bottom: 1em }
+
+div.line-block div.line-block {
+  margin-top: 0 ;
+  margin-bottom: 0 ;
+  margin-left: 1.5em }
+
+div.sidebar {
+  margin: 0 0 0.5em 1em ;
+  border: medium outset ;
+  padding: 1em ;
+  background-color: #ffffee ;
+  width: 40% ;
+  float: right ;
+  clear: right }
+
+div.sidebar p.rubric {
+  font-family: sans-serif ;
+  font-size: medium }
+
+div.system-messages {
+  margin: 5em }
+
+div.system-messages h1 {
+  color: red }
+
+div.system-message {
+  border: medium outset ;
+  padding: 1em }
+
+div.system-message p.system-message-title {
+  color: red ;
+  font-weight: bold }
+
+div.topic {
+  margin: 2em }
+
+h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
+h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
+  margin-top: 0.4em }
+
+h1.title {
+  text-align: center }
+
+h2.subtitle {
+  text-align: center }
+
+hr.docutils {
+  width: 75% }
+
+img.align-left, .figure.align-left, object.align-left, table.align-left {
+  clear: left ;
+  float: left ;
+  margin-right: 1em }
+
+img.align-right, .figure.align-right, object.align-right, table.align-right {
+  clear: right ;
+  float: right ;
+  margin-left: 1em }
+
+img.align-center, .figure.align-center, object.align-center {
+  display: block;
+  margin-left: auto;
+  margin-right: auto;
+}
+
+table.align-center {
+  margin-left: auto;
+  margin-right: auto;
+}
+
+.align-left {
+  text-align: left }
+
+.align-center {
+  clear: both ;
+  text-align: center }
+
+.align-right {
+  text-align: right }
+
+/* reset inner alignment in figures */
+div.align-right {
+  text-align: inherit }
+
+/* div.align-center * { */
+/*   text-align: left } */
+
+.align-top    {
+  vertical-align: top }
+
+.align-middle {
+  vertical-align: middle }
+
+.align-bottom {
+  vertical-align: bottom }
+
+ol.simple, ul.simple {
+  margin-bottom: 1em }
+
+ol.arabic {
+  list-style: decimal }
+
+ol.loweralpha {
+  list-style: lower-alpha }
+
+ol.upperalpha {
+  list-style: upper-alpha }
+
+ol.lowerroman {
+  list-style: lower-roman }
+
+ol.upperroman {
+  list-style: upper-roman }
+
+p.attribution {
+  text-align: right ;
+  margin-left: 50% }
+
+p.caption {
+  font-style: italic }
+
+p.credits {
+  font-style: italic ;
+  font-size: smaller }
+
+p.label {
+  white-space: nowrap }
+
+p.rubric {
+  font-weight: bold ;
+  font-size: larger ;
+  color: maroon ;
+  text-align: center }
+
+p.sidebar-title {
+  font-family: sans-serif ;
+  font-weight: bold ;
+  font-size: larger }
+
+p.sidebar-subtitle {
+  font-family: sans-serif ;
+  font-weight: bold }
+
+p.topic-title {
+  font-weight: bold }
+
+pre.address {
+  margin-bottom: 0 ;
+  margin-top: 0 ;
+  font: inherit }
+
+pre.literal-block, pre.doctest-block, pre.math, pre.code {
+  margin-left: 2em ;
+  margin-right: 2em }
+
+pre.code .ln { color: grey; } /* line numbers */
+pre.code, code { background-color: #eeeeee }
+pre.code .comment, code .comment { color: #5C6576 }
+pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
+pre.code .literal.string, code .literal.string { color: #0C5404 }
+pre.code .name.builtin, code .name.builtin { color: #352B84 }
+pre.code .deleted, code .deleted { background-color: #DEB0A1}
+pre.code .inserted, code .inserted { background-color: #A3D289}
+
+span.classifier {
+  font-family: sans-serif ;
+  font-style: oblique }
+
+span.classifier-delimiter {
+  font-family: sans-serif ;
+  font-weight: bold }
+
+span.interpreted {
+  font-family: sans-serif }
+
+span.option {
+  white-space: nowrap }
+
+span.pre {
+  white-space: pre }
+
+span.problematic {
+  color: red }
+
+span.section-subtitle {
+  /* font-size relative to parent (h1..h6 element) */
+  font-size: 80% }
+
+table.citation {
+  border-left: solid 1px gray;
+  margin-left: 1px }
+
+table.docinfo {
+  margin: 2em 4em }
+
+table.docutils {
+  margin-top: 0.5em ;
+  margin-bottom: 0.5em }
+
+table.footnote {
+  border-left: solid 1px black;
+  margin-left: 1px }
+
+table.docutils td, table.docutils th,
+table.docinfo td, table.docinfo th {
+  padding-left: 0.5em ;
+  padding-right: 0.5em ;
+  vertical-align: top }
+
+table.docutils th.field-name, table.docinfo th.docinfo-name {
+  font-weight: bold ;
+  text-align: left ;
+  white-space: nowrap ;
+  padding-left: 0 }
+
+/* "booktabs" style (no vertical lines) */
+table.docutils.booktabs {
+  border: 0px;
+  border-top: 2px solid;
+  border-bottom: 2px solid;
+  border-collapse: collapse;
+}
+table.docutils.booktabs * {
+  border: 0px;
+}
+table.docutils.booktabs th {
+  border-bottom: thin solid;
+  text-align: left;
+}
+
+h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
+h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
+  font-size: 100% }
+
+ul.auto-toc {
+  list-style-type: none }
+
+</style>
+</head>
+<body>
+<div class="document" id="connector-search-engine">
+<h1 class="title">Connector Search Engine</h1>
+
+<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+!! This file is generated by oca-gen-addon-readme !!
+!! changes will be overwritten.                   !!
+!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
+<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/search-engine/tree/14.0/connector_search_engine"><img alt="OCA/search-engine" src="https://img.shields.io/badge/github-OCA%2Fsearch--engine-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/search-engine-14-0/search-engine-14-0-connector_search_engine"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/276/14.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
+<p>Base module for connecting Odoo with external search engines.</p>
+<p><strong>Table of contents</strong></p>
+<div class="contents local topic" id="contents">
+<ul class="simple">
+<li><a class="reference internal" href="#known-issues-roadmap" id="id1">Known issues / Roadmap</a></li>
+<li><a class="reference internal" href="#changelog" id="id2">Changelog</a><ul>
+<li><a class="reference internal" href="#x-y-z-yyyy-mm-dd" id="id3">12.0.x.y.z (YYYY-MM-DD)</a></li>
+</ul>
+</li>
+<li><a class="reference internal" href="#bug-tracker" id="id4">Bug Tracker</a></li>
+<li><a class="reference internal" href="#credits" id="id5">Credits</a><ul>
+<li><a class="reference internal" href="#authors" id="id6">Authors</a></li>
+<li><a class="reference internal" href="#contributors" id="id7">Contributors</a></li>
+<li><a class="reference internal" href="#maintainers" id="id8">Maintainers</a></li>
+</ul>
+</li>
+</ul>
+</div>
+<div class="section" id="known-issues-roadmap">
+<h1><a class="toc-backref" href="#id1">Known issues / Roadmap</a></h1>
+<ul class="simple">
+<li>Implement generic trigger for binding
+based on ir.export linked to the index
+(the aim is to set the binding to be updated
+if we modify a field configured in the exporter)</li>
+</ul>
+</div>
+<div class="section" id="changelog">
+<h1><a class="toc-backref" href="#id2">Changelog</a></h1>
+<div class="section" id="x-y-z-yyyy-mm-dd">
+<h2><a class="toc-backref" href="#id3">12.0.x.y.z (YYYY-MM-DD)</a></h2>
+<p>TODO</p>
+</div>
+</div>
+<div class="section" id="bug-tracker">
+<h1><a class="toc-backref" href="#id4">Bug Tracker</a></h1>
+<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/search-engine/issues">GitHub Issues</a>.
+In case of trouble, please check there if your issue has already been reported.
+If you spotted it first, help us smashing it by providing a detailed and welcomed
+<a class="reference external" href="https://github.com/OCA/search-engine/issues/new?body=module:%20connector_search_engine%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
+<p>Do not contact contributors directly about support or help with technical issues.</p>
+</div>
+<div class="section" id="credits">
+<h1><a class="toc-backref" href="#id5">Credits</a></h1>
+<div class="section" id="authors">
+<h2><a class="toc-backref" href="#id6">Authors</a></h2>
+<ul class="simple">
+<li>Akretion</li>
+<li>ACSONE SA/NV</li>
+<li>Camptocamp</li>
+</ul>
+</div>
+<div class="section" id="contributors">
+<h2><a class="toc-backref" href="#id7">Contributors</a></h2>
+<ul class="simple">
+<li>Sébastien BEAU &lt;<a class="reference external" href="mailto:sebastien.beau&#64;akretion.com">sebastien.beau&#64;akretion.com</a>&gt;</li>
+<li>Laurent Mignon &lt;<a class="reference external" href="mailto:laurent.mignon&#64;acsone.eu">laurent.mignon&#64;acsone.eu</a>&gt;</li>
+<li>Simone Orsi &lt;<a class="reference external" href="mailto:simone.orsi&#64;camptocamp.com">simone.orsi&#64;camptocamp.com</a>&gt;</li>
+<li>Raphaël Reverdy &lt;<a class="reference external" href="mailto:raphael.reverdy&#64;akretion.com">raphael.reverdy&#64;akretion.com</a>&gt;</li>
+</ul>
+</div>
+<div class="section" id="maintainers">
+<h2><a class="toc-backref" href="#id8">Maintainers</a></h2>
+<p>This module is maintained by the OCA.</p>
+<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
+<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.</p>
+<p>This module is part of the <a class="reference external" href="https://github.com/OCA/search-engine/tree/14.0/connector_search_engine">OCA/search-engine</a> project on GitHub.</p>
+<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
+</div>
+</div>
+</div>
+</body>
+</html>
diff --git a/connector_search_engine/views/se_backend.xml b/connector_search_engine/views/se_backend.xml
new file mode 100644
index 00000000..4c600ef2
--- /dev/null
+++ b/connector_search_engine/views/se_backend.xml
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<!-- Copyright 2013 Akretion (http://www.akretion.com)
+     License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
+<odoo>
+    <record model="ir.ui.view" id="se_backend_form_view">
+        <field name="name">se.backend.form (in connector_search_engine)</field>
+        <field name="model">se.backend</field>
+        <field name="arch" type="xml">
+            <form create="false" edit="false">
+                <sheet>
+                    <label for="name" class="oe_edit_only" />
+                    <h1>
+                        <field name="name" class="oe_inline" required="1" />
+                    </h1>
+                    <group name="se" string="Configuration">
+                        <group colspan="4" col="4" name="se-main">
+                            <field name="tech_name" colspan="2" />
+                            <field name="index_prefix_name" colspan="2" />
+                            <field name="specific_backend" colspan="2" />
+                        </group>
+                    </group>
+                    <group name="index" string="Index">
+                        <field
+                            name="index_ids"
+                            nolabel="1"
+                            context="{
+                                'tree_view_ref': 'connector_search_engine.se_index_tree_no_backend_view',
+                                'from_backend_view': True
+                            }"
+                        />
+                    </group>
+                </sheet>
+            </form>
+        </field>
+    </record>
+    <record model="ir.ui.view" id="se_backend_tree_view">
+        <field name="name">se.backend.tree (in connector_search_engine)</field>
+        <field name="model">se.backend</field>
+        <field name="arch" type="xml">
+            <tree create="false">
+                <field name="tech_name" />
+                <field name="specific_backend" />
+                <field name="specific_model" />
+            </tree>
+        </field>
+    </record>
+    <record model="ir.actions.act_window" id="se_backend_act_window">
+        <field name="name">Search Engine Backend</field>
+        <field name="res_model">se.backend</field>
+        <field name="view_mode">tree,form</field>
+        <field name="domain">[]</field>
+        <field name="context">{}</field>
+    </record>
+</odoo>
diff --git a/connector_search_engine/views/se_index.xml b/connector_search_engine/views/se_index.xml
new file mode 100644
index 00000000..235974b8
--- /dev/null
+++ b/connector_search_engine/views/se_index.xml
@@ -0,0 +1,92 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<!-- Copyright 2019 ACSONE SA/NV
+     License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
+<odoo>
+    <record model="ir.ui.view" id="se_index_tree_view">
+        <field name="name">se.index.tree (in connector_search_engine)</field>
+        <field name="model">se.index</field>
+        <field name="arch" type="xml">
+            <tree editable="bottom">
+                <field name="custom_tech_name" optional="hide" />
+                <field
+                    name="backend_id"
+                    invisible="context.get('from_backend_view', False)"
+                />
+                <field name="name" />
+                <field name="lang_id" />
+                <field name="model_id" />
+                <field
+                    name="exporter_id"
+                    context="{'form_view_ref': 'base_jsonify.view_ir_exports'}"
+                />
+                <field name="config_id" />
+                <field name="batch_size" />
+                <button
+                    name="force_recompute_all_binding"
+                    help="Force to recompute all binding of the index and set them to the state 'To update'"
+                    icon="fa-gears"
+                    type="object"
+                />
+                <button
+                    name="force_batch_export"
+                    help="Force the export of all binding"
+                    icon="fa-upload"
+                    type="object"
+                />
+                <button
+                    name="clear_index"
+                    help="Clear the index in the search engine"
+                    icon="fa-times"
+                    type="object"
+                    groups="connector_search_engine.group_connector_search_engine_manager"
+                />
+            </tree>
+        </field>
+    </record>
+    <record model="ir.ui.view" id="se_index_tree_no_backend_view">
+        <field
+            name="name"
+        >se.index.tree with backend hidden (in connector_search_engine)</field>
+        <field name="model">se.index</field>
+        <field name="mode">primary</field>
+        <field name="inherit_id" ref="connector_search_engine.se_index_tree_view" />
+        <field name="arch" type="xml">
+            <field name="backend_id" position="attributes">
+                <attribute name="invisible">1</attribute>
+            </field>
+        </field>
+    </record>
+    <record model="ir.ui.view" id="se_index_search_view">
+        <field name="name">se.index.search (in connector_search_engine)</field>
+        <field name="model">se.index</field>
+        <field name="arch" type="xml">
+            <search>
+                <field name="backend_id" />
+                <field name="name" />
+                <field name="lang_id" />
+                <field name="model_id" />
+                <group expand="0" string="Group By...">
+                    <filter
+                        string="Backend"
+                        name="backend"
+                        domain="[]"
+                        context="{'group_by':'backend_id'}"
+                    />
+                    <filter
+                        string="Model"
+                        name="model"
+                        domain="[]"
+                        context="{'group_by':'model_id'}"
+                    />
+                </group>
+            </search>
+        </field>
+    </record>
+    <record model="ir.actions.act_window" id="se_index_act_window">
+        <field name="name">Se Index</field>
+        <field name="res_model">se.index</field>
+        <field name="view_mode">tree,form</field>
+        <field name="domain">[]</field>
+        <field name="context">{'default_group_by_backend': 1}</field>
+    </record>
+</odoo>
diff --git a/connector_search_engine/views/se_index_config.xml b/connector_search_engine/views/se_index_config.xml
new file mode 100644
index 00000000..da957863
--- /dev/null
+++ b/connector_search_engine/views/se_index_config.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<!-- Copyright 2019 ACSONE SA/NV
+     License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
+<odoo>
+    <record model="ir.ui.view" id="se_index_config_form_view">
+        <field name="name">se.index.config.form (in connector_search_engine)</field>
+        <field name="model">se.index.config</field>
+        <field name="arch" type="xml">
+            <form>
+                <header>
+                </header>
+                <sheet>
+                    <group>
+                        <field name="name" />
+                        <field name="body_str" required="1" />
+                    </group>
+                </sheet>
+                <div class="oe_chatter">
+                    <field name="message_ids" widget="mail_thread" />
+                </div>
+            </form>
+        </field>
+    </record>
+    <record model="ir.ui.view" id="se_index_config_tree_view">
+        <field name="name">se.index.config.tree (in connector_search_engine)</field>
+        <field name="model">se.index.config</field>
+        <field name="arch" type="xml">
+            <tree>
+                <field name="name" />
+            </tree>
+        </field>
+    </record>
+    <record model="ir.actions.act_window" id="se_index_config_act_window">
+        <field name="name">Se Index Config</field>
+        <field name="res_model">se.index.config</field>
+        <field name="view_mode">tree,form</field>
+        <field name="domain">[]</field>
+        <field name="context">{}</field>
+    </record>
+</odoo>
diff --git a/connector_search_engine/views/se_menu.xml b/connector_search_engine/views/se_menu.xml
new file mode 100644
index 00000000..ce1a9450
--- /dev/null
+++ b/connector_search_engine/views/se_menu.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<odoo>
+    <menuitem
+        id="se_root_menu"
+        parent="connector.menu_connector_root"
+        name="Search Engine Connector"
+        sequence="10"
+        groups="connector.group_connector_manager"
+    />
+    <menuitem
+        id="se_backend_menu"
+        name="Backends"
+        parent="se_root_menu"
+        action="se_backend_act_window"
+    />
+    <menuitem
+        id="se_index_menu"
+        name="Indexes"
+        parent="se_root_menu"
+        action="se_index_act_window"
+    />
+    <menuitem
+        id="se_index_config_menu"
+        name="Index configurations"
+        parent="se_root_menu"
+        action="se_index_config_act_window"
+    />
+</odoo>
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 00000000..6f3f1c6f
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+# generated from manifests external_dependencies
+unidecode
diff --git a/setup/connector_search_engine/odoo/addons/connector_search_engine b/setup/connector_search_engine/odoo/addons/connector_search_engine
new file mode 120000
index 00000000..7920b59a
--- /dev/null
+++ b/setup/connector_search_engine/odoo/addons/connector_search_engine
@@ -0,0 +1,1 @@
+../../../../connector_search_engine
\ No newline at end of file
diff --git a/setup/connector_search_engine/setup.py b/setup/connector_search_engine/setup.py
new file mode 100644
index 00000000..28c57bb6
--- /dev/null
+++ b/setup/connector_search_engine/setup.py
@@ -0,0 +1,6 @@
+import setuptools
+
+setuptools.setup(
+    setup_requires=['setuptools-odoo'],
+    odoo_addon=True,
+)


Test patch:
diff --git a/connector_search_engine/tests/__init__.py b/connector_search_engine/tests/__init__.py
new file mode 100644
index 00000000..7836283b
--- /dev/null
+++ b/connector_search_engine/tests/__init__.py
@@ -0,0 +1,1 @@
+from . import test_all
diff --git a/connector_search_engine/tests/common.py b/connector_search_engine/tests/common.py
new file mode 100644
index 00000000..b18f51bb
--- /dev/null
+++ b/connector_search_engine/tests/common.py
@@ -0,0 +1,47 @@
+# Copyright 2018 Simone Orsi - Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+import logging
+from urllib import parse as urlparse
+
+from odoo import tools
+from odoo.modules.module import get_resource_path
+
+from odoo.addons.component.tests.common import TransactionComponentCase
+
+# mute `test_queue_job_no_delay` logging
+logging.getLogger("odoo.addons.queue_job.models.base").setLevel("CRITICAL")
+
+
+def load_xml(env, module, filepath):
+    tools.convert_file(
+        env.cr,
+        module,
+        get_resource_path(module, filepath),
+        {},
+        mode="init",
+        noupdate=False,
+        kind="test",
+    )
+
+
+class TestSeBackendCaseBase(TransactionComponentCase):
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.env = cls.env(
+            context=dict(
+                cls.env.context,
+                tracking_disable=True,  # speed up tests
+                test_queue_job_no_delay=True,  # no jobs thanks
+            )
+        )
+        cls.se_index_model = cls.env["se.index"]
+
+    @classmethod
+    def _load_fixture(cls, fixture, module="connector_search_engine"):
+        load_xml(cls.env, module, "tests/fixtures/%s" % fixture)
+
+    @staticmethod
+    def parse_path(url):
+        return urlparse.urlparse(url).path
diff --git a/connector_search_engine/tests/fixtures/ir_exports_test.xml b/connector_search_engine/tests/fixtures/ir_exports_test.xml
new file mode 100644
index 00000000..026910f7
--- /dev/null
+++ b/connector_search_engine/tests/fixtures/ir_exports_test.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<odoo>
+    <record id="ir_exp_partner_test" model="ir.exports">
+        <field name="name">Test Partner Export</field>
+        <field name="resource">res.partner.binding.fake</field>
+    </record>
+    <record id="ir_exp_partner_line_test0" model="ir.exports.line">
+        <field name="export_id" ref="ir_exp_partner_test" />
+        <field name="name">record_id</field>
+        <field name="target">record_id:id</field>
+        <field name="instance_method_name">_jsonify_m2o_to_id</field>
+    </record>
+    <record id="ir_exp_partner_line_test1" model="ir.exports.line">
+        <field name="export_id" ref="ir_exp_partner_test" />
+        <field name="name">name</field>
+    </record>
+    <record id="ir_exp_partner_line_test2" model="ir.exports.line">
+        <field name="export_id" ref="ir_exp_partner_test" />
+        <field name="name">active</field>
+    </record>
+    <record id="ir_exp_partner_line_test3" model="ir.exports.line">
+        <field name="export_id" ref="ir_exp_partner_test" />
+        <field name="name">lang</field>
+    </record>
+    <record id="ir_exp_partner_line_test4" model="ir.exports.line">
+        <field name="export_id" ref="ir_exp_partner_test" />
+        <field name="name">credit_limit</field>
+    </record>
+    <record id="ir_exp_partner_line_test5" model="ir.exports.line">
+        <field name="export_id" ref="ir_exp_partner_test" />
+        <field name="name">color</field>
+    </record>
+    <record id="ir_exp_partner_line_test6" model="ir.exports.line">
+        <field name="export_id" ref="ir_exp_partner_test" />
+        <field name="name">category_id/name</field>
+    </record>
+    <record id="ir_exp_partner_line_test6" model="ir.exports.line">
+        <field name="export_id" ref="ir_exp_partner_test" />
+        <field name="name">country_id/name</field>
+    </record>
+    <record id="ir_exp_partner_line_test7" model="ir.exports.line">
+        <field name="export_id" ref="ir_exp_partner_test" />
+        <field name="name">country_id/code</field>
+    </record>
+    <record id="ir_exp_partner_line_test8" model="ir.exports.line">
+        <field name="export_id" ref="ir_exp_partner_test" />
+        <field name="name">child_ids/id</field>
+    </record>
+    <record id="ir_exp_partner_line_test9" model="ir.exports.line">
+        <field name="export_id" ref="ir_exp_partner_test" />
+        <field name="name">child_ids/name</field>
+    </record>
+    <record id="ir_exp_partner_line_test10" model="ir.exports.line">
+        <field name="export_id" ref="ir_exp_partner_test" />
+        <field name="name">child_ids/email</field>
+    </record>
+    <record id="ir_exp_partner_line_test11" model="ir.exports.line">
+        <field name="export_id" ref="ir_exp_partner_test" />
+        <field name="name">child_ids/country_id/name</field>
+    </record>
+    <record id="ir_exp_partner_line_test12" model="ir.exports.line">
+        <field name="export_id" ref="ir_exp_partner_test" />
+        <field name="name">child_ids/country_id/code</field>
+    </record>
+    <record id="ir_exp_partner_line_test13" model="ir.exports.line">
+        <field name="export_id" ref="ir_exp_partner_test" />
+        <field name="name">child_ids/child_ids/name</field>
+    </record>
+</odoo>
diff --git a/connector_search_engine/tests/models.py b/connector_search_engine/tests/models.py
new file mode 100644
index 00000000..4546103c
--- /dev/null
+++ b/connector_search_engine/tests/models.py
@@ -0,0 +1,106 @@
+# Copyright 2018 Simone Orsi - Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from contextlib import contextmanager
+
+from odoo import fields, models
+
+from odoo.addons.component.core import Component
+
+
+class SeBackendFake(models.Model):
+
+    _name = "se.backend.fake"
+    _inherit = "se.backend.spec.abstract"
+    _description = "Unit Test SE Backend"
+    _search_engine_name = "FakeSE"
+    _record_id_key = "id"
+
+
+class SeAdapterFake(Component):
+    _name = "se.adapter.fake"
+    _inherit = "se.backend.adapter"
+    _usage = "se.backend.adapter"
+    _collection = SeBackendFake._name
+
+    def __init__(self, work_context):
+        super().__init__(work_context)
+        if not hasattr(self, "_mocked_calls"):
+            # Not using the context manager below
+            self._mocked_calls = []
+
+    def index(self, data):
+        self._mocked_calls.append(
+            dict(work_ctx=self.work.__dict__, method="index", args=data)
+        )
+
+    def delete(self, binding_ids):
+        self._mocked_calls.append(
+            dict(work_ctx=self.work.__dict__, method="delete", args=binding_ids)
+        )
+
+    def clear(self):
+        self._mocked_calls.append(
+            dict(work_ctx=self.work.__dict__, method="clear", args=None)
+        )
+
+    def each(self):
+        self._mocked_calls.append(
+            dict(work_ctx=self.work.__dict__, method="each", args=None)
+        )
+        return [{"id": 42}]
+
+    @classmethod
+    @contextmanager
+    def mocked_calls(cls):
+        """Handle mocking of calls.
+
+        Usage:
+
+            with SeAdapterFake.mocked_calls() as calls:
+                # do something, then
+                self.assertEqual(calls[0]['method'], 'clear')
+                # do more
+        """
+        cls._mocked_calls = []
+        yield cls._mocked_calls
+        cls._mocked_calls = []
+
+
+# Fake partner binding
+
+
+class BindingResPartnerFake(models.Model):
+    _name = "res.partner.binding.fake"
+    _inherit = ["se.binding"]
+    _inherits = {"res.partner": "record_id"}
+    # we need to reference this model for the index
+
+    # TODO: use autosetup fields to handle these fields in mixins
+    record_id = fields.Many2one(
+        comodel_name="res.partner",
+        string="Odoo record",
+        required=True,
+        ondelete="cascade",
+    )
+
+    def synchronize(self):
+        # You can set `call_tracking` as a list in ctx to collect the results.
+        res = super().synchronize()
+        if "call_tracking" in self.env.context:
+            self.env.context["call_tracking"].append(res)
+        return res
+
+
+class ResPartnerFake(models.Model):
+    _name = "res.partner"
+    _inherit = "res.partner"
+
+    # TODO: use autosetup fields to handle these fields in mixins
+    binding_ids = fields.One2many(
+        comodel_name=BindingResPartnerFake._name,
+        inverse_name="record_id",
+        copy=False,
+        string="Bindings",
+        context={"active_test": False},
+        manual=True,  # required to make teardown work
+    )
diff --git a/connector_search_engine/tests/test_all.py b/connector_search_engine/tests/test_all.py
new file mode 100644
index 00000000..0d17b2c6
--- /dev/null
+++ b/connector_search_engine/tests/test_all.py
@@ -0,0 +1,419 @@
+# Copyright 2018 Simone Orsi - Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+import mock
+from odoo_test_helper import FakeModelLoader
+
+from odoo.tests.common import Form
+from odoo.tools import mute_logger
+
+from .common import TestSeBackendCaseBase
+
+
+class TestBindingIndexBase(TestSeBackendCaseBase, FakeModelLoader):
+    @classmethod
+    def setUpClass(cls):
+        res = super().setUpClass()
+        # Load fake models ->/
+        cls.loader = FakeModelLoader(cls.env, cls.__module__)
+        cls.loader.backup_registry()
+        from .models import (
+            BindingResPartnerFake,
+            ResPartnerFake,
+            SeAdapterFake,
+            SeBackendFake,
+        )
+
+        cls.loader.update_registry(
+            (BindingResPartnerFake, ResPartnerFake, SeBackendFake)
+        )
+        cls.binding_model = cls.env[BindingResPartnerFake._name]
+        cls.fake_backend_model = cls.env[SeBackendFake._name]
+        # ->/ Load fake models
+
+        cls.se_adapter_fake = SeAdapterFake
+        cls._load_fixture("ir_exports_test.xml")
+        cls.exporter = cls.env.ref("connector_search_engine.ir_exp_partner_test")
+        cls.record_id_export_line = cls.env.ref(
+            "connector_search_engine.ir_exp_partner_line_test0"
+        )
+        return res
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.loader.restore_registry()
+        return super().tearDownClass()
+
+    @classmethod
+    def _prepare_index_values(cls, backend=None):
+        backend = backend or cls.backend
+        return {
+            "name": "Partner Index",
+            "backend_id": backend.id,
+            "model_id": cls.env["ir.model"]
+            .search([("model", "=", "res.partner.binding.fake")], limit=1)
+            .id,
+            "lang_id": cls.env.ref("base.lang_en").id,
+            "exporter_id": cls.exporter.id,
+        }
+
+    @classmethod
+    def setup_records(cls, backend=None):
+        backend = backend or cls.backend
+        # create an index for partner model
+        cls.se_index = cls.se_index_model.create(cls._prepare_index_values(backend))
+        # create a binding + partner alltogether
+        cls.partner_binding = cls.binding_model.create(
+            {
+                "name": "Marty McFly",
+                "country_id": cls.env.ref("base.us").id,
+                "email": "marty.mcfly@future.com",
+                "child_ids": [
+                    (0, 0, {"name": "Doc Brown", "email": "docbrown@future.com"})
+                ],
+                "index_id": cls.se_index.id,
+            }
+        )
+        cls.partner = cls.partner_binding.record_id
+
+
+class TestBindingIndexBaseFake(TestBindingIndexBase):
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.se_adapter_fake._build_component(cls._components_registry)
+
+        cls.backend_specific = cls.fake_backend_model.create(
+            {"name": "Fake SE", "tech_name": "fake_se"}
+        )
+        cls.backend = cls.backend_specific.se_backend_id
+        cls.setup_records()
+
+
+class TestBindingIndex(TestBindingIndexBaseFake):
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+
+    # TODO: the following `test_backend*` methods
+    # should be splitted to a smaller test case.
+    # ATM is not possible since the teardown of this class
+    # is going to drop the fake models and make any subsequent test case fail.
+    # We should find a way to run tear down at the end of ALL test cases.
+    def test_backend_specific_select(self):
+        self.assertIn(
+            self.fake_backend_model._name,
+            [x[0] for x in self.env["se.backend"]._select_specific_model()],
+        )
+
+    def test_backend_specific_records(self):
+        self.assertIn(
+            (self.fake_backend_model._name, self.backend_specific.name),
+            self.env["se.backend"]._select_specific_backend(),
+        )
+
+    def test_backend_defaults(self):
+        self.assertEqual(self.backend.specific_model, self.backend_specific._name)
+
+    def test_backend_unlink(self):
+        self.assertTrue(self.backend_specific.exists())
+        self.assertTrue(self.backend.exists())
+        self.backend_specific.unlink()
+        self.assertFalse(self.backend_specific.exists())
+        self.assertFalse(self.backend.exists())
+
+    def test_backend_name(self):
+        form = Form(self.env["se.backend"])
+        form.name = "Á weird nämë plenty of CR@P!"
+        # tech name normalized
+        self.assertEqual(form.tech_name, "a_weird_name_plenty_of_cr_p")
+        self.assertEqual(form.index_prefix_name, "a_weird_name_plenty_of_cr_p")
+        form.tech_name = "better_name"
+        form.index_prefix_name = "better_prefix"
+        form.name = "My search backend"
+        # name updated, tech names stay
+        self.assertEqual(form.tech_name, "better_name")
+        self.assertEqual(form.index_prefix_name, "better_prefix")
+
+    def test_backend_create_tech_defaults(self):
+        b1 = self.fake_backend_model.create({"name": "Fake 1"})
+        self.assertEqual(b1.tech_name, "fake_1")
+        self.assertEqual(b1.index_prefix_name, "fake_1")
+        b2 = self.fake_backend_model.create({"name": "Fake 2", "tech_name": "test2"})
+        self.assertEqual(b2.tech_name, "test2")
+        self.assertEqual(b2.index_prefix_name, "test2")
+        b3 = self.fake_backend_model.create(
+            {"name": "Fake 3", "tech_name": "test3", "index_prefix_name": "baz"}
+        )
+        self.assertEqual(b3.tech_name, "test3")
+        self.assertEqual(b3.index_prefix_name, "baz")
+
+    def test_index_name(self):
+        self.assertEqual(self.se_index.name, "fake_se_res_partner_binding_fake_en_US")
+        # control indexes' name via prefix tech name
+        self.backend.index_prefix_name = "foo_baz"
+        # TODO: not sure why this is needed here
+        self.se_index.invalidate_cache()
+        self.assertEqual(self.se_index.name, "foo_baz_res_partner_binding_fake_en_US")
+        self.se_index.lang_id = False
+        self.se_index.invalidate_cache()
+        self.assertEqual(self.se_index.name, "foo_baz_res_partner_binding_fake")
+
+    def test_index_custom_name(self):
+        self.se_index.custom_tech_name = "something meaningful for me"
+        self.assertEqual(
+            self.se_index.name, "fake_se_something_meaningful_for_me_en_US"
+        )
+
+    def test_changing_model_remove_exporter(self):
+        res = self.se_index.onchange_model_id()
+        self.assertEqual(len(self.se_index.exporter_id), 0)
+        self.assertIn("domain", res)
+        self.assertIn("exporter_id", res["domain"])
+
+    def test_model_id_domain(self):
+        result = self.se_index._model_id_domain()
+        models = result[0][2]
+        self.assertIn("res.partner.binding.fake", models)
+
+    def test_recompute_all_indexes(self):
+        # on creation indexes are computed and external data stored
+        expected = {
+            "id": self.partner_binding.record_id.id,
+            "active": True,
+            "lang": "en_US",
+            "name": "Marty McFly",
+            "credit_limit": 0.0,
+            "country_id": {"code": "US", "name": "United States"},
+            "color": 0,
+            "child_ids": [
+                {
+                    "id": self.partner.child_ids[0].id,
+                    "name": "Doc Brown",
+                    "email": "docbrown@future.com",
+                    "child_ids": [],
+                    "country_id": {"code": "US", "name": "United States"},
+                }
+            ],
+        }
+        self.assertEqual(self.partner_binding.get_export_data(), expected)
+        self.assertEqual(self.partner_binding.sync_state, "to_update")
+        # on index recompute external data must be updated
+        self.partner.name = "George McFly"
+        expected["name"] = self.partner.name
+        self.env["se.index"].recompute_all_index()
+        self.assertEqual(self.partner_binding.get_export_data(), expected)
+        self.assertEqual(self.partner_binding.sync_state, "to_update")
+        self.assertNotEqual(self.partner_binding.date_modified, False)
+
+    def test_force_recompute_all_binding(self):
+        with mock.patch.object(type(self.se_index), "recompute_all_binding") as mocked:
+            self.se_index.force_recompute_all_binding()
+        mocked.assert_called_with(force_export=True)
+
+    def test_force_batch_export(self):
+        # "new" should not be sync'ed but it will because we run forced export
+        self.partner_binding.sync_state = "new"
+        tracking = []
+        self.se_index.with_context(call_tracking=tracking).force_batch_export()
+        self.assertEqual(tracking, ["Exported ids : [1]\nDeleted ids : []"])
+        self.assertEqual(self.partner_binding.sync_state, "scheduled")
+        self.assertNotEqual(self.partner_binding.date_syncronized, False)
+
+    def test_generate_batch_export_per_index(self):
+        tracking = []
+        self.env["se.index"].with_context(
+            call_tracking=tracking
+        ).generate_batch_export_per_index()
+        self.assertEqual(tracking, ["Exported ids : [1]\nDeleted ids : []"])
+
+    def test_get_domain_for_exporting_binding(self):
+        expected = [
+            ("index_id", "=", self.se_index.id),
+            ("sync_state", "=", "to_update"),
+        ]
+        self.assertEqual(self.se_index._get_domain_for_exporting_binding(), expected)
+
+    def test_batch_export(self):
+        # state = new, nothing to export
+        self.partner_binding.sync_state = "new"
+        tracking = []
+        self.se_index.with_context(call_tracking=tracking).batch_export()
+        self.assertEqual(tracking, [])
+        self.assertEqual(self.partner_binding.sync_state, "new")
+
+        # now it should find the bindings marked for update and schedule them
+        self.partner_binding.sync_state = "to_update"
+        tracking = []
+        self.se_index.with_context(call_tracking=tracking).batch_export()
+        self.assertEqual(tracking, ["Exported ids : [1]\nDeleted ids : []"])
+        self.assertEqual(self.partner_binding.sync_state, "scheduled")
+
+        # even if the binding is inactive it should schedule and delete them
+        self.partner_binding.sync_state = "to_update"
+        self.partner_binding.active = False
+        tracking = []
+        self.se_index.with_context(call_tracking=tracking).batch_export()
+        self.assertEqual(tracking, ["Exported ids : []\nDeleted ids : [1]"])
+        self.assertEqual(self.partner_binding.sync_state, "scheduled")
+
+    def test_clear_index(self):
+        with self.se_adapter_fake.mocked_calls() as calls:
+            self.se_index.clear_index()
+            self.assertEqual(len(calls), 1)
+            self.assertEqual(calls[0]["work_ctx"]["index"], self.se_index)
+            self.assertEqual(calls[0]["method"], "clear")
+
+    def test_synchronize_active_binding(self):
+        # when binding is active it should update it
+        with self.se_adapter_fake.mocked_calls() as calls:
+            self.partner_binding.synchronize()
+            self.assertEqual(len(calls), 1)
+            self.assertEqual(calls[0]["work_ctx"]["index"], self.se_index)
+            self.assertEqual(calls[0]["work_ctx"]["records"], self.partner_binding)
+            self.assertEqual(calls[0]["method"], "index")
+
+    def test_synchronize_inactive_binding(self):
+        # when binding is inactive it should delete it
+        self.partner_binding.active = False
+        with self.se_adapter_fake.mocked_calls() as calls:
+            self.partner_binding.synchronize()
+            self.assertEqual(len(calls), 1)
+            self.assertEqual(calls[0]["work_ctx"]["index"], self.se_index)
+            self.assertEqual(calls[0]["work_ctx"]["records"], self.partner_binding)
+            self.assertEqual(calls[0]["method"], "delete")
+
+    def test_batch_delete(self):
+        # when a binding is unlinked, it should delete it
+        partner_binding_id = self.partner_binding.id
+        self.partner_binding.unlink()
+        self.assertIn(
+            partner_binding_id,
+            self.se_index.binding_todelete_ids.mapped("binding_id"),
+            "Binding should be queued for deletion",
+        )
+        with self.se_adapter_fake.mocked_calls() as calls:
+            self.se_index.batch_export()
+            self.assertEqual(len(calls), 1)
+            self.assertEqual(calls[0]["method"], "delete")
+            self.assertEqual(calls[0]["args"], [partner_binding_id])
+        self.assertNotIn(
+            partner_binding_id,
+            self.se_index.binding_todelete_ids.mapped("binding_id"),
+            "Binding should've been removed from the delete queue",
+        )
+
+    def test_unlink_new_binding(self):
+        self.partner_binding.sync_state = "new"
+        self.partner_binding.unlink()
+        self.assertEqual(len(self.partner_binding.exists()), 0)
+
+    def test_unlink_done_inactive_binding(self):
+        self.partner_binding.active = False
+        self.partner_binding.sync_state = "done"
+        self.partner_binding.unlink()
+        self.assertEqual(len(self.partner_binding.exists()), 0)
+
+    def test_unlink_to_update_inactive_binding(self):
+        partner_binding_id = self.partner_binding.id
+        self.partner_binding.active = False
+        self.partner_binding.sync_state = "to_update"
+        self.partner_binding.unlink()
+        self.assertIn(
+            partner_binding_id, self.se_index.binding_todelete_ids.mapped("binding_id")
+        )
+        self.assertEqual(len(self.partner_binding.exists()), 0)
+
+    def test_unlink_to_upgrade_active_binding(self):
+        partner_binding_id = self.partner_binding.id
+        self.partner_binding.sync_state = "to_update"
+        self.partner_binding.unlink()
+        self.assertIn(
+            partner_binding_id, self.se_index.binding_todelete_ids.mapped("binding_id")
+        )
+        self.assertEqual(len(self.partner_binding.exists()), 0)
+
+    def test_unlink_done_active_binding(self):
+        partner_binding_id = self.partner_binding.id
+        self.partner_binding.sync_state = "done"
+        self.partner_binding.unlink()
+        self.assertIn(
+            partner_binding_id, self.se_index.binding_todelete_ids.mapped("binding_id")
+        )
+        self.assertEqual(len(self.partner_binding.exists()), 0)
+
+    def test_inactive_binding_change_state(self):
+        self.partner_binding.sync_state = "done"
+        self.partner_binding.active = False
+        self.assertEqual(self.partner_binding.sync_state, "to_update")
+
+    def test_inactive_new_binding_do_not_change_state(self):
+        self.partner_binding.sync_state = "new"
+        self.partner_binding.active = False
+        self.assertEqual(self.partner_binding.sync_state, "new")
+
+    def test_resynchronize_all_bindings(self):
+        # The iter method in the mock will return the item id 42 that do not
+        # exist as we have dropped all binding
+        # the resync method will call the delete to remove this obsolete item
+        bindings = self.binding_model.search([])
+        bindings.write({"sync_state": "new"})
+        bindings.unlink()
+        with self.se_adapter_fake.mocked_calls() as calls:
+            self.se_index.resynchronize_all_bindings()
+            self.assertEqual(len(calls), 2)
+            self.assertEqual(calls[0]["work_ctx"]["index"], self.se_index)
+            self.assertEqual(calls[0]["method"], "each")
+            self.assertEqual(calls[1]["work_ctx"]["index"], self.se_index)
+            self.assertEqual(calls[1]["method"], "delete")
+            self.assertEqual(calls[1]["args"], [42])
+
+    @mute_logger("odoo.addons.connector_search_engine.models.se_binding")
+    def test_recompute_json_to_be_checked(self):
+        # When something goes wrong on recomputing index data
+        # the state is properly set to `to_be_checked`
+        self.assertNotEqual(self.partner_binding.sync_state, "to_be_checked")
+        with mock.patch.object(
+            type(self.partner_binding), "_validate_record"
+        ) as mocked:
+            mocked.return_value = "Something wrong with data"
+            result = self.partner_binding.recompute_json()
+        self.assertEqual(self.partner_binding.sync_state, "to_be_checked")
+        self.assertEqual(
+            result,
+            "Validation errors\n"
+            "res.partner.binding.fake(%s,): Something wrong with data"
+            % self.partner_binding.id,
+        )
+
+    def test_recompute_json_to_be_checked_rollback(self):
+        # If something was to check but it's now good,
+        # the state should be back to normal
+        self.partner_binding.sync_state = "to_be_checked"
+        self.partner_binding.name = "Data changes, binding is written"
+        result = self.partner_binding.recompute_json()
+        self.assertEqual(self.partner_binding.sync_state, "to_update")
+        self.assertEqual(result, "")
+
+    def test_customize_id_key_without_target(self):
+        self.env["ir.exports.line"].create(
+            {"export_id": self.exporter.id, "name": "id"}
+        )
+        self.partner_binding.recompute_json()
+        self.assertEqual(self.partner_binding.data["id"], self.partner_binding.id)
+
+    def test_customize_id_key_with_target(self):
+        self.env["ir.exports.line"].create(
+            {"export_id": self.exporter.id, "name": "name:id"}
+        )
+        self.partner_binding.recompute_json()
+        self.assertEqual(self.partner_binding.data["id"], "Marty McFly")
+
+    @mute_logger("odoo.addons.connector_search_engine.models.se_binding")
+    def test_missing_record_key(self):
+        self.record_id_export_line.unlink()
+        res = self.partner_binding.recompute_json()
+        error_string = "\n".join(
+            ["Validation errors", "{}: The key `id` is missing in:"]
+        ).format(str(self.partner_binding))
+        self.assertTrue(res.startswith(error_string))