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

Issue:
10.0 sync 12



Discussion / hints:

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 8cd4a924..97dcaf43 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -3,7 +3,6 @@ exclude: |
   # NOT INSTALLABLE ADDONS
   ^connector_algolia/|
   ^connector_elasticsearch/|
-  ^connector_search_engine/|
   # END NOT INSTALLABLE ADDONS
   # Files and folders generated by bots, to avoid loops
   ^setup/|/static/description/index\.html$|
diff --git a/connector_search_engine/README.rst b/connector_search_engine/README.rst
index f64fd0e0..12dfb0dd 100644
--- a/connector_search_engine/README.rst
+++ b/connector_search_engine/README.rst
@@ -14,24 +14,146 @@ Connector Search Engine
     :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
+    :target: https://github.com/OCA/search-engine/tree/16.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
+    :target: https://translation.odoo-community.org/projects/search-engine-16-0/search-engine-16-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
+    :target: https://runbot.odoo-community.org/runbot/276/16.0
     :alt: Try me on Runbot
 
 |badge1| |badge2| |badge3| |badge4| |badge5| 
 
-Base module for connecting Odoo with external search engines.
+Base module for connecting Odoo with external search engines. This addon is
+intended to be used as a base for other addons that implement specific search
+engines. It's designed to be easily extensible and modular.
 
 **Table of contents**
 
 .. contents::
    :local:
 
+Installation
+============
+
+This addon uses the native json python package provided by python. When
+a json for a record is recomputed, the new value is compared to the original
+one to see if an export to the search engine index is needed.  This is
+done by comparing the md5 of the two json strings. This process when done on
+a large number of records can be slow when the json is large and complex. To speed
+up this process you can install the orjson package.
+
+.. code-block:: bash
+
+    pip install orjson
+
+Usage
+=====
+
+Overview
+~~~~~~~~
+
+A search engine is a system designed to store information in a way that makes
+it easy to find through search and analytics queries. The main difference
+between a search engine and a database is that a search engine is optimized
+for search and analytics queries, while a database is optimized for
+transactional and relational queries.
+
+This addons is designed around 4 main concepts:
+
+* **The search engine backend** is used to define into Odoo the kind
+  of search engine that will be used to index the data. It's main responsibility
+  is to provide an instance of `odoo.addons.search_engine.tools.adapter.SearchEngineAdapter`
+  that will be used to communicate with the search engine.
+
+* **The search engine index** is used to define into Odoo the index where
+  the data will be indexed. An index is always linked to a search engine backend.
+  The index provides methods to use to manage the lifecycle of the data put into
+  the index for the records of a given model. To do so, it uses:
+
+  * **The SearchEngineAdapter** provided by the backend to communicate with the
+    search engine.
+  * **A ModelSerializer** that is used to transform an odoo record into
+    a dictionary that can be indexed into the search engine.
+  * **A JsonValidator** that is used to validate the data that is to be
+    indexed into the search engine.
+
+  The RecordSerializer and IndexDataValidator are defined on the index itself.
+  The current addon provides a default implementation only for the IndexDataValidator.
+  You can find into the github repository `search-engine <https://github.com:
+  OCA/search-engine/tree/16.0>`_ An implementation of the RecordSerializer based
+  on the jsonifier addon `connector_search_engine_jsonifier`.
+
+* **The search engine indexable record** is a mixin that is used to define
+  the records that can be indexed into a search engine index. The mixin
+  provides methods:
+
+  * To add a record to an index.
+  * To remove a record from an index.
+  * To mark the record into an index (*the search engine bindings*) as to be
+    recomputed (This method should be called when modifications are made on
+    the record that could impact the data that are indexed into the search
+    engine. It will instruct the index that the record must be recomputed and
+    re-indexed).
+
+  It also ensures that when the record is unlinked, it is removed from the indexes
+  it was indexed into.
+
+* **The search engine binding** is a model that represents the link between
+  an index and an indexable odoo record. It give you access to the data
+  that are indexed into the search engine for the record. It's also used to
+  manage the lifecycle of the data into the search engine. When a binding is
+  created, it's marked as to be computed. Once the data are computed, the
+  binding is marked as to be indexed. Once the data are indexed, the binding
+  is marked as indexed. If the linked record is unlinked, the binding is
+  marked as to be removed. Once the data are removed from the search engine,
+  the binding is deleted.
+
+Indexing lifecycle
+~~~~~~~~~~~~~~~~~~
+
+The indexing lifecycle is based on the following steps:
+
+* When a record is added to an index, a binding is created and marked as to be
+  computed.
+* A cron job scheduled every 5 minutes will look for bindings that are to be
+  computed and for each of them will schedule a job to re compute the json data.
+* When the json data is computed, the binding is marked as to be exported if the
+  json is valid and is different from the one that has been computed last time.
+* A cron job scheduled every 5 minutes will ensure the syncing with the search
+  engine. It will:
+
+  * look for bindings that are to be exported and for each of them will schedule
+    a job to export the json data into the search engine. Once exported, the
+    binding is marked as 'done'.
+  * look for bindings that are to be removed and for each of them will schedule
+    a job to remove the data from the search engine. Once removed, the binding
+    is deleted.
+
+To keep in sync the data from your model instance and the data that are indexed
+into the search engine, you should call the method `_se_mark_to_update` on the
+mode instance when you make modifications that could impact the data that are
+indexed into the search engine.
+
+* When the method `_se_mark_to_update` is called, the binding is marked as to be
+  computed.
+* From there, the same process as described above will be used to recompute the
+  data and reindex them into the search engine.
+
+When a model instance is unlinked, the binding is marked as to be removed. From
+there if will be processed by the job syncing the data with the search engine.
+
+.. note::
+
+  In previous versions of this addon, there was no method to mark a record as
+  to be recomputed. As a consequence, all the records were re-computed every day
+  to ensure that the data in the search engine were up to date. This was a
+  performance issue and consumed a lot of resources. If despite this, you want
+  to recompute all the records every day, you can activate the cron jon
+  `Search engine: recompute all index` and deactivate the one named
+  `earch engine: Generate job for recompute binding to recompute per index`.
+
 Known issues / Roadmap
 ======================
 
@@ -54,7 +176,7 @@ 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**>`_.
+`feedback <https://github.com/OCA/search-engine/issues/new?body=module:%20connector_search_engine%0Aversion:%2016.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.
 
@@ -89,6 +211,6 @@ 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.
+This module is part of the `OCA/search-engine <https://github.com/OCA/search-engine/tree/16.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
index f94ed854..fbdd7c8a 100644
--- a/connector_search_engine/__init__.py
+++ b/connector_search_engine/__init__.py
@@ -3,4 +3,4 @@
 # 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
+from . import tools
diff --git a/connector_search_engine/__manifest__.py b/connector_search_engine/__manifest__.py
index cc866278..28026c07 100644
--- a/connector_search_engine/__manifest__.py
+++ b/connector_search_engine/__manifest__.py
@@ -4,7 +4,7 @@
 
 {
     "name": "Connector Search Engine",
-    "version": "14.0.2.6.2",
+    "version": "16.0.0.0.0",
     "author": "Akretion,"
     "ACSONE SA/NV,"
     "Camptocamp,"
@@ -12,7 +12,7 @@
     "website": "https://github.com/OCA/search-engine",
     "license": "AGPL-3",
     "category": "Generic Modules",
-    "depends": ["connector", "jsonifier", "server_environment"],
+    "depends": ["queue_job", "mail", "server_environment"],
     "external_dependencies": {"python": ["unidecode"]},
     "data": [
         "security/connector_search_engine_security.xml",
@@ -20,11 +20,13 @@
         "security/ir.model.access.csv",
         "views/se_backend.xml",
         "views/se_index.xml",
+        "views/se_binding_view.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",
+        "data/ir_action_data.xml",
     ],
-    "installable": False,
+    "installable": True,
 }
diff --git a/connector_search_engine/components/__init__.py b/connector_search_engine/components/__init__.py
deleted file mode 100644
index ec03a7f8..00000000
--- a/connector_search_engine/components/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# 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
deleted file mode 100644
index e7342281..00000000
--- a/connector_search_engine/components/adapter.py
+++ /dev/null
@@ -1,36 +0,0 @@
-# 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
deleted file mode 100644
index ae7bc466..00000000
--- a/connector_search_engine/components/core.py
+++ /dev/null
@@ -1,16 +0,0 @@
-# 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
deleted file mode 100644
index 65c7b9d6..00000000
--- a/connector_search_engine/components/deleter.py
+++ /dev/null
@@ -1,26 +0,0 @@
-# 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
deleted file mode 100644
index 4b0a7736..00000000
--- a/connector_search_engine/components/exporter.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# 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
deleted file mode 100644
index cb30537e..00000000
--- a/connector_search_engine/components/mapper.py
+++ /dev/null
@@ -1,24 +0,0 @@
-# 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_action_data.xml b/connector_search_engine/data/ir_action_data.xml
new file mode 100644
index 00000000..91dad878
--- /dev/null
+++ b/connector_search_engine/data/ir_action_data.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<odoo>
+
+    <record model="ir.actions.server" id="action_recompute_se_index">
+        <field name="name">Recompute Search Index</field>
+        <field name="model_id" ref="connector_search_engine.model_se_binding" />
+        <field name="state">code</field>
+        <field name="code">model.recompute_from_owner()</field>
+    </record>
+
+</odoo>
diff --git a/connector_search_engine/data/ir_cron.xml b/connector_search_engine/data/ir_cron.xml
index 2bcd5bbb..33582bcb 100644
--- a/connector_search_engine/data/ir_cron.xml
+++ b/connector_search_engine/data/ir_cron.xml
@@ -1,8 +1,8 @@
 <?xml version="1.0" encoding="utf-8" ?>
 <odoo noupdate="1">
-    <record forcecreate="True" id="ir_cron_recompute_all_index" model="ir.cron">
+    <record 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="active" eval="False" />
         <field name="user_id" ref="base.user_root" />
         <field name="interval_number">1</field>
         <field name="interval_type">days</field>
@@ -12,31 +12,34 @@
         <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">
+    <record 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="interval_number">5</field>
+        <field name="interval_type">minutes</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="code">model.generate_batch_sync_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" />
+    <record id="ir_cron_recompute_binding" model="ir.cron">
+        <field
+            name="name"
+        >Search engine: Generate job for recompute binding to recompute 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="interval_number">5</field>
+        <field name="interval_type">minutes</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>
+        <field name="code">model.generate_batch_recompute_per_index()</field>
+        <field name="priority" eval="6" />
     </record>
 </odoo>
diff --git a/connector_search_engine/data/queue_job_function_data.xml b/connector_search_engine/data/queue_job_function_data.xml
index d9beed26..d5625b39 100644
--- a/connector_search_engine/data/queue_job_function_data.xml
+++ b/connector_search_engine/data/queue_job_function_data.xml
@@ -12,17 +12,14 @@
         <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">
+    <record id="job_function_se_binding_export_record" model="queue.job.function">
         <field name="model_id" ref="model_se_binding" />
-        <field name="method">synchronize</field>
+        <field name="method">export_record</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>
+    <record id="job_function_se_binding_delete_record" model="queue.job.function">
+        <field name="model_id" ref="model_se_binding" />
+        <field name="method">delete_record</field>
         <field name="channel_id" ref="channel_search_engine_synchronize" />
     </record>
     <record id="job_function_se_index_batch_export" model="queue.job.function">
@@ -30,9 +27,20 @@
         <field name="method">batch_export</field>
         <field name="channel_id" ref="channel_search_engine_synchronize" />
     </record>
+    <record id="job_function_se_index_batch_recompute" model="queue.job.function">
+        <field name="model_id" ref="model_se_index" />
+        <field name="method">batch_recompute</field>
+        <field name="channel_id" ref="channel_search_engine_recompute" />
+    </record>
+    <record id="job_function_se_index_batch_sync" model="queue.job.function">
+        <field name="model_id" ref="model_se_index" />
+        <field name="method">batch_sync</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/migrations/14.0.1.1.0/pre-migrate.py b/connector_search_engine/migrations/14.0.1.1.0/pre-migrate.py
deleted file mode 100644
index bae87ac1..00000000
--- a/connector_search_engine/migrations/14.0.1.1.0/pre-migrate.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright 2021 Camptocamp SA (http://www.camptocamp.com).
-# @author Simone Orsi <simone.orsi@camptocamp.com>
-# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
-
-from odoo.tools.sql import set_not_null
-
-
-def migrate(cr, version):
-    set_not_null(cr, "se_index", "lang_id")
diff --git a/connector_search_engine/models/__init__.py b/connector_search_engine/models/__init__.py
index 94e04f69..98a52d3f 100644
--- a/connector_search_engine/models/__init__.py
+++ b/connector_search_engine/models/__init__.py
@@ -2,10 +2,9 @@
 # Sébastien BEAU <sebastien.beau@akretion.com>
 # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
 
-
+from . import ir_actions
 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
+from . import se_indexable_record
diff --git a/connector_search_engine/models/ir_actions.py b/connector_search_engine/models/ir_actions.py
new file mode 100644
index 00000000..9db34a39
--- /dev/null
+++ b/connector_search_engine/models/ir_actions.py
@@ -0,0 +1,27 @@
+# Copyright 2023 Akretion (https://www.akretion.com).
+# @author Sébastien BEAU <sebastien.beau@akretion.com>
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import models, tools
+from odoo.tools import frozendict
+
+
+class IrActions(models.Model):
+    _inherit = "ir.actions.actions"
+
+    @tools.ormcache("model_name", "self.env.lang")
+    def _get_bindings(self, model_name):
+        res = super()._get_bindings(model_name)
+        model = self.env[model_name]
+        if hasattr(model, "_se_indexable") and model._se_indexable:
+            res = dict(res)
+            res["action"] = tuple(
+                list(res["action"])
+                + [
+                    self.env["ir.actions.actions"]._for_xml_id(
+                        "connector_search_engine.action_recompute_se_index"
+                    )
+                ]
+            )
+            res = frozendict(res)
+        return res
diff --git a/connector_search_engine/models/se_backend.py b/connector_search_engine/models/se_backend.py
index 78ec9125..1585a4bc 100644
--- a/connector_search_engine/models/se_backend.py
+++ b/connector_search_engine/models/se_backend.py
@@ -1,7 +1,12 @@
 # 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
+from typing import Type
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+from ..tools.adapter import SearchEngineAdapter
 
 
 class SeBackend(models.Model):
@@ -9,7 +14,6 @@ class SeBackend(models.Model):
     _name = "se.backend"
     _description = "Se Backend"
     _inherit = [
-        "connector.backend",
         "server.env.techname.mixin",
         "server.env.mixin",
     ]
@@ -19,78 +23,51 @@ class SeBackend(models.Model):
         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
-    )
+    backend_type = fields.Selection(selection=[], string="Type", required=True)
+
     index_ids = fields.One2many("se.index", "backend_id")
-    specific_backend = fields.Reference(
-        string="Specific backend",
-        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):
-        super()._onchange_tech_name()
+        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):
-        super()._handle_tech_name(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
+
+    def _get_adapter_class(self) -> Type[SearchEngineAdapter]:
+        """Return the adapter class for this backend"""
+        raise NotImplementedError
+
+    def get_adapter(self, index=None) -> SearchEngineAdapter:
+        """Return an instance of the adapter for this backend"""
+        adapter = self._get_adapter_class()
+        if adapter:
+            return adapter(self, index)
+        else:
+            raise UserError(_("Adapter is missing for type %s") % self.backend_type)
+
+    def action_test_connection(self):
+        adapter = self.get_adapter()
+        adapter.test_connection()
+        return {
+            "type": "ir.actions.client",
+            "tag": "display_notification",
+            "params": {
+                "title": _("Connection Test Succeeded!"),
+                "message": _("Everything seems properly set up!"),
+                "sticky": False,
+            },
+        }
diff --git a/connector_search_engine/models/se_backend_spec_abstract.py b/connector_search_engine/models/se_backend_spec_abstract.py
deleted file mode 100644
index 617c2d22..00000000
--- a/connector_search_engine/models/se_backend_spec_abstract.py
+++ /dev/null
@@ -1,72 +0,0 @@
-# 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 `%s` is missing in:\n%s") % (self._record_id_key, record)
diff --git a/connector_search_engine/models/se_binding.py b/connector_search_engine/models/se_binding.py
index faec59a2..452d4549 100644
--- a/connector_search_engine/models/se_binding.py
+++ b/connector_search_engine/models/se_binding.py
@@ -2,30 +2,36 @@
 # 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 typing import Any, Dict, Iterator
+
+from odoo import _, api, fields, models, tools
+from odoo.exceptions import ValidationError
+
+from odoo.addons.queue_job.job import identity_exact
 
-from odoo import _, api, fields, models
+from ..tools.json_comparer import compare_json
 
 _logger = logging.getLogger(__name__)
 
 
-class SeBinding(models.AbstractModel):
+class SeBinding(models.Model):
     _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"
+    _description = "Search Engine Record"
+
+    # when we recompute several binding we order by res_model, res_id so for a
+    # record indexed in several index all the binding will be recomputed
+    # in the same job as index have a lot of common data most of the field will
+    # be in cache after computing the first binding
+    _order = "res_model, res_id desc"
+
+    backend_id = fields.Many2one(
+        "se.backend",
+        related="index_id.backend_id",
+        string="Search Engine Backend",
+        store=True,
+        readonly=True,
     )
     index_id = fields.Many2one(
         "se.index",
@@ -33,168 +39,156 @@ class SeBinding(models.AbstractModel):
         required=True,
         # TODO: shall we use 'restrict' here to preserve existing data?
         ondelete="cascade",
+        readonly=True,
     )
-    sync_state = fields.Selection(
+    state = fields.Selection(
         [
-            ("new", "New"),
-            ("to_update", "To update"),
-            ("scheduled", "Scheduled"),
+            ("to_recompute", "To recompute"),
+            ("recomputing", "Recomputing"),
+            ("to_export", "To export"),
+            ("exporting", "Exporting"),
             ("done", "Done"),
-            ("to_be_checked", "To be checked"),
+            ("invalid_data", "Invalid Data"),
+            ("recompute_error", "Fail to Recompute"),
+            ("to_delete", "To Delete"),
+            ("deleting", "Deleting"),
         ],
-        default="new",
+        default="to_recompute",
         readonly=True,
     )
-    date_modified = fields.Datetime(readonly=True)
-    date_syncronized = fields.Datetime(readonly=True)
-    data = fields.Serialized()
+    date_recomputed = fields.Datetime(readonly=True)
+    date_synchronized = fields.Datetime(readonly=True)
+    data = fields.Json(readonly=True)
     data_display = fields.Text(
         compute="_compute_data_display",
         help="Include this in debug mode to be able to inspect index data.",
     )
-    active = fields.Boolean(string="Active", default=True)
+    error = fields.Text()
+    res_id = fields.Many2oneReference(
+        string="Record ID",
+        help="ID of the target record in the database",
+        model_field="res_model",
+        readonly=True,
+    )
+    res_model = fields.Selection(
+        selection=lambda s: s._get_indexable_model_selection(), readonly=True
+    )
+
+    _sql_constraints = [
+        (
+            "item_uniq_per_index",
+            "unique(res_id, res_model, index_id)",
+            _("A record can only be bind one time per index !"),
+        ),
+    ]
+
+    @tools.ormcache()
+    @api.model
+    def _get_indexable_model_selection(self):
+        return [
+            (model, self.env[model]._description)
+            for model in self.env
+            if (
+                hasattr(self.env[model], "_se_indexable")
+                and not self.env[model]._abstract
+                and not self.env[model]._transient
+            )
+        ]
+
+    @property
+    def record(self) -> models.Model:
+        if len(set(self.mapped("res_model"))) > 1:
+            raise ValueError("All record must have the same model")
+        return self.env[self[0].res_model].browse(self.mapped("res_id")).exists()
 
     @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):
+    def get_export_data(self) -> Dict[str, Any]:
         """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
+    def _batch(self, size: int) -> Iterator["SeBinding"]:
+        """Return an iterator on the records in batch of size."""
+        for i in range(0, len(self), size):
+            yield self[i : i + size]
 
-    @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 %s json and check if need update" % self._name)
+    def jobify_recompute_json(self, force_export: bool = False):
+        """Create batch job for records to recompute the json."""
         # 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
+        size = min(self.index_id.mapped("batch_exporting_size"))
+        for binding in self.with_context(tracking_disable=True)._batch(size):
+            description = _(
+                "Recompute %(name)s json and check if need update", name=self._name
             )
+            binding.with_delay(
+                description=description, identity_key=identity_exact
+            ).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."""
+    def recompute_json(self, force_export: bool = 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)
+
+        for record in self.sudo():
+            if not record.record:
+                record.state = "to_delete"
+                _logger.error(
+                    "There is something wrong, the record do not exists "
+                    "flag the binding to be deleted"
+                )
+                continue
+            index = record.index_id
+            # force the lang if needed
+            if index.lang_id:
+                record = record.with_context(lang=index.lang_id.code)
+
+            old_data = record.data
+            try:
+                with self.env.cr.savepoint():
+                    record.data = index.model_serializer.serialize(record.record)
+                    record.date_recomputed = fields.Datetime.now()
+            except Exception as e:
+                record.state = "recompute_error"
+                record.error = str(e)
+                continue
+            try:
+                index.json_validator.validate(record.data or {})
+            except ValidationError as e:
+                record.state = "invalid_data"
+                record.error = str(e)
+            else:
+                if force_export or not compare_json(old_data or {}, record.data or {}):
+                    record.state = "to_export"
+                else:
+                    record.state = "done"
+                record.error = ""
+
+    def export_record(self) -> str:
+        adapter = self.index_id.se_adapter
+        adapter.index([record.get_export_data() for record in self])
+        self.state = "done"
+        return "Exported ids : {}".format(self.ids)
+
+    def delete_record(self) -> str:
+        adapter = self.index_id.se_adapter
+        record_ids = self.mapped("res_id")
+        adapter.delete(record_ids)
+        self.unlink()
+        return "Deleted ids : {}".format(record_ids)
+
+    def recompute_from_owner(self):
+        bindings = self.search(
+            [
+                ("res_model", "=", self._context["active_model"]),
+                ("res_id", "in", self._context["active_ids"]),
+            ]
+        )
+        bindings.write({"state": "recomputing"})
+        bindings.jobify_recompute_json()
diff --git a/connector_search_engine/models/se_binding_todelete.py b/connector_search_engine/models/se_binding_todelete.py
deleted file mode 100644
index 5797fa7f..00000000
--- a/connector_search_engine/models/se_binding_todelete.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# 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
index 2e031ba7..77d3d4ea 100644
--- a/connector_search_engine/models/se_index.py
+++ b/connector_search_engine/models/se_index.py
@@ -2,17 +2,26 @@
 # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
 
 import logging
+from collections import defaultdict
+from typing import List
 
 from odoo import _, api, fields, models
 
+from odoo.addons.queue_job.job import identity_exact
+
+from ..tools.adapter import SearchEngineAdapter
+from ..tools.serializer import ModelSerializer
+from ..tools.validator import DefaultJsonValidator, JsonValidator
+
 _logger = logging.getLogger(__name__)
 
 
 class SeIndex(models.Model):
-
     _name = "se.index"
     _description = "Se Index"
 
+    __slots__ = ("_se_adapter", "_model_serializer", "_json_validator")
+
     name = fields.Char(compute="_compute_name", store=True)
     custom_tech_name = fields.Char(
         help="Take control of index technical name. "
@@ -31,28 +40,73 @@ class SeIndex(models.Model):
         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")
+    serializer_type = fields.Selection([])
+    batch_exporting_size = fields.Integer(
+        default=1000, help="Batch size for exporting element"
+    )
+    batch_recomputing_size = fields.Integer(
+        default=50, help="Batch size for recomputing 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,
-    )
+    binding_ids = fields.One2many("se.binding", "index_id", "Binding")
+    color = fields.Integer(string="Color Index", compute="_compute_count_binding")
+    count_done = fields.Integer(compute="_compute_count_binding")
+    count_pending = fields.Integer(compute="_compute_count_binding")
+    count_error = fields.Integer(compute="_compute_count_binding")
+
+    @api.depends("binding_ids.state")
+    def _compute_count_binding(self):
+        res = defaultdict(lambda: defaultdict(int))
+        data = self.env["se.binding"].read_group(
+            [
+                ("index_id", "in", self.ids),
+            ],
+            ["index_id", "state"],
+            groupby=["index_id", "state"],
+            lazy=False,
+        )
+        for item in data:
+            res[item["index_id"][0]][item["state"]] = item["__count"]
+
+        def get(index_id, states):
+            return sum([res[index_id][state] for state in states])
+
+        for record in self:
+            record.count_done = get(record.id, ["done"])
+            record.count_pending = get(
+                record.id,
+                [
+                    "to_recompute",
+                    "recomputing",
+                    "to_export",
+                    "exporting",
+                    "to_delete",
+                    "deleting",
+                ],
+            )
+            record.count_error = get(record.id, ["invalid_data", "recompute_error"])
+            if record.count_error:
+                record.color = 1
+            elif record.count_pending:
+                record.color = 2
+            else:
+                record.color = 10
+
+    def __init__(self, env, ids=(), prefetch_ids=()):
+        super().__init__(env, ids, prefetch_ids)
+        self._se_adapter = None
+        self._model_serializer = None
+        self._json_validator = None
 
     @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)
+        se_model_names = [
+            x[0] for x in self.env["se.binding"]._get_indexable_model_selection()
+        ]
         return [("model", "in", se_model_names)]
 
     _sql_constraints = [
@@ -63,43 +117,56 @@ def _model_id_domain(self):
         )
     ]
 
-    @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}}
+    def write(self, vals):
+        res = super().write(vals)
+        if "backend_id" in vals:
+            for index in self:
+                index._se_adapter = None
+        return res
+
+    @property
+    def se_adapter(self) -> SearchEngineAdapter:
+        if not self._se_adapter:
+            self._se_adapter = self.backend_id.get_adapter(self)
+        return self._se_adapter
+
+    @property
+    def model_serializer(self) -> ModelSerializer:
+        if not self._model_serializer:
+            self._model_serializer = self._get_serializer()
+        return self._model_serializer
+
+    @property
+    def json_validator(self) -> JsonValidator:
+        if not self._json_validator:
+            self._json_validator = self._get_validator()
+        return self._json_validator
+
+    def _get_serializer(self) -> ModelSerializer:
+        raise NotImplementedError
+
+    def _get_validator(self) -> JsonValidator:
+        return DefaultJsonValidator()
 
     @api.model
-    def recompute_all_index(self, domain=None):
+    def recompute_all_index(self, domain=None) -> None:
         if domain is None:
             domain = []
-        return self.search(domain).recompute_all_binding()
+        self.search(domain).recompute_all_binding()
 
-    def force_recompute_all_binding(self):
-        for record in self:
-            record.recompute_all_binding(force_export=True)
+    def force_recompute_all_binding(self) -> None:
+        self.recompute_all_binding(force_export=True)
 
-    def recompute_all_binding(self, force_export=False, batch_size=500):
+    def recompute_all_binding(self, force_export: bool = False):
         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
+            indexes.binding_ids.jobify_recompute_json(force_export=force_export)
 
     @api.depends(
         "custom_tech_name", "lang_id", "model_id", "backend_id.index_prefix_name"
     )
-    def _compute_name(self):
+    def _compute_name(self) -> None:
         for rec in self:
             if not rec.backend_id:
                 # in onchange on concrete views rec is a new Id
@@ -108,7 +175,7 @@ def _compute_name(self):
             else:
                 rec.name = rec._make_name()
 
-    def _make_name(self):
+    def _make_name(self) -> str:
         """Compute the final name of the index."""
         name = ""
         backend = self.backend_id
@@ -128,88 +195,141 @@ def _make_name(self):
             name = "_".join(bits)
         return name
 
-    def _make_tech_name(self):
+    def _make_tech_name(self) -> str:
         """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):
+    def force_batch_sync(self) -> None:
+        self.ensure_one()
+        self._jobify_batch_sync(force_export=True)
+
+    def _jobify_batch_sync(self, force_export: bool = False) -> None:
         self.ensure_one()
-        self._jobify_batch_export(force_export=True)
+        description = _("Prepare a batch synchronization of index '%s'") % self.name
+        self.with_delay(
+            description=description, identity_key=identity_exact
+        ).batch_sync(force_export)
 
-    def _jobify_batch_export(self, force_export=False):
+    def _jobify_batch_recompute(self, force_export: bool = False) -> None:
         self.ensure_one()
-        description = _("Prepare a batch export of index '%s'") % self.name
-        self.with_delay(description=description).batch_export(force_export)
+        description = _("Prepare a batch recompute of index '%s'") % self.name
+        self.with_delay(
+            description=description, identity_key=identity_exact
+        ).batch_recompute(force_export)
 
     @api.model
-    def generate_batch_export_per_index(self, domain=None):
+    def generate_batch_sync_per_index(self, domain: list | None = None) -> None:
+        """Generate a job for each index to sync.
+
+        This method is usually called by a cron. It will generate a job for each
+        index to sync. The sync will export the bindings marked as to_export.
+        and will delete the bindings marked as to_delete.
+        """
+        if domain is None:
+            domain = []
+        for record in self.search(domain):
+            record._jobify_batch_sync()
+
+    @api.model
+    def generate_batch_recompute_per_index(self, domain: list | None = None) -> None:
+        """Generate a job for each index to recompute.
+
+        This method is usually called by a cron. It will generate a job for each
+        index to recompute. The recompute process will recompute the bindings
+        marked as to_recompute.
+        """
         if domain is None:
             domain = []
         for record in self.search(domain):
-            record._jobify_batch_export()
-        return True
+            record._jobify_batch_recompute()
 
-    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 _get_domain_for_recomputing_binding(self, force_export: bool = False) -> list:
+        """Return the domain to use to find the bindings to recompute."""
+        states = ["to_recompute"]
+        if force_export:
+            states.append("recomputing")
+        return [("index_id", "=", self.id), ("state", "in", states)]
 
-    def _batch_export(self, force_export=False):
+    def batch_recompute(self, force_export: bool = False) -> None:
+        """Recompute all the bindings of the index marked as to_recompute."""
+        self.ensure_one()
+        domain = self._get_domain_for_recomputing_binding(force_export)
+        bindings = self.env["se.binding"].search(domain)
+        bindings_count = len(bindings)
+        for batch in bindings._batch(self.batch_recomputing_size):
+            description = _(
+                "Recompute %(processing_count)d records of %(total_count)d "
+                "for index '%(index_name)s'"
+            ) % {
+                "processing_count": len(batch),
+                "total_count": bindings_count,
+                "index_name": self.name,
+            }
+            batch.write({"state": "recomputing"})
+            batch.with_delay(description=description).recompute_json()
+
+    def _get_domain_for_exporting_binding(self, force_export: bool = False):
+        """Return the domain to use to find the bindings to export."""
+        states = ["to_export"]
+        if force_export:
+            states.extend(["done", "exporting"])
+        return [("index_id", "=", self.id), ("state", "in", states)]
+
+    def _batch_export(self, force_export: bool = False) -> None:
+        """Export all the bindings of the index marked as to_export."""
         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 = self.env["se.binding"].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 %d records of %d for index '%s'") % (
-                len(processing),
-                bindings_count,
-                self.name,
-            )
-            processing.with_delay(description=description).synchronize()
-            processing.with_context(connector_no_export=True).write(
-                {"sync_state": "scheduled"}
+        for batch in bindings._batch(self.batch_exporting_size):
+            description = _(
+                "Export %(processing_count)d records of %(total_count)d "
+                "for index '%(index_name)s'"
+            ) % dict(
+                processing_count=len(batch),
+                total_count=bindings_count,
+                index_name=self.name,
             )
+            batch.write({"state": "exporting"})
+            batch.with_delay(description=description).export_record()
+
+    def _get_domain_for_deleting_binding(self, force_export: bool = False) -> list:
+        """Get the domain to search the bindings to delete."""
+        states = ["to_delete"]
+        if force_export:
+            states.append("deleting")
+        return [("index_id", "=", self.id), ("state", "in", states)]
 
-    def _batch_delete(self):
+    def _batch_delete(self, force_export: bool = True) -> None:
+        """Delete all the bindings of the index marked as to_delete."""
         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
+        domain = self._get_domain_for_deleting_binding(force_export)
+        bindings = self.env["se.binding"].search(domain)
+        bindings_count = len(bindings)
+        for batch in bindings._batch(self.batch_exporting_size):
             description = _(
-                "Delete %d obsolete records of %d for index '%s'",
-                len(processing),
-                binding_todelete_count,
-                self.name,
+                "Delete %(processing_count)d obsolete records of %(total_count)d "
+                "for index '%(index_name)s'",
+            ) % dict(
+                processing_count=len(batch),
+                total_count=bindings_count,
+                index_name=self.name,
             )
-            processing.with_delay(description=description).synchronize()
+            batch.write({"state": "deleting"})
+            batch.with_delay(description=description).delete_record()
 
-    def batch_export(self, force_export=False):
+    def batch_sync(self, force_export: bool = False) -> None:
         self.ensure_one()
         self._batch_export(force_export=force_export)
-        self._batch_delete()
-        return True
+        self._batch_delete(force_export=force_export)
 
-    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):
+    def clear_index(self) -> None:
         self.ensure_one()
-        adapter = self._get_backend_adapter()
+        adapter = self.se_adapter
         adapter.clear()
-        return True
 
-    def _get_settings(self):
+    def _get_settings(self) -> dict:
         """
         Override this method is sub modules in order to pass the adequate
         settings (like Facetting, pagination, advanced settings, etc...)
@@ -218,15 +338,19 @@ def _get_settings(self):
         return {}
 
     @api.model
-    def export_all_settings(self):
+    def export_all_settings(self) -> None:
         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 export_settings(self) -> None:
+        self.ensure_one()
+        adapter = self.se_adapter
+        adapter.settings()
+
+    def reindex(self) -> None:
+        """Reindex records according."""
+        self.ensure_one()
+        adapter = self.se_adapter
+        adapter.reindex()
 
     def resynchronize_all_bindings(self):
         """Force sync between Odoo records and index records.
@@ -234,23 +358,21 @@ def resynchronize_all_bindings(self):
         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 should not use this method for day to day job, it's only an helper
+        for recovering your index after corruption.
         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)
+            adapter = self.se_adapter
             binding_model = self.env[index.model_id.model]
             for index_record in adapter.each():
-                ext_id = adapter.external_id(index_record)
+                ext_id = adapter._get_odoo_id_from_index_data(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)
+    def delete_obsolete_item(self, item_ids: List[int]):
+        self.se_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
index 54c2482c..d60e87e0 100644
--- a/connector_search_engine/models/se_index_config.py
+++ b/connector_search_engine/models/se_index_config.py
@@ -22,7 +22,7 @@ class SeIndexConfig(models.Model):
         compute="_compute_body_str",
         inverse="_inverse_body_str",
         default="{}",
-        track_visibility="onchange",
+        tracking=True,
     )
 
     @api.depends("body")
diff --git a/connector_search_engine/models/se_indexable_record.py b/connector_search_engine/models/se_indexable_record.py
new file mode 100644
index 00000000..4400b67b
--- /dev/null
+++ b/connector_search_engine/models/se_indexable_record.py
@@ -0,0 +1,188 @@
+# Copyright 2022 Akretion (https://www.akretion.com).
+# @author Sébastien BEAU <sebastien.beau@akretion.com>
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from __future__ import annotations
+
+from collections import defaultdict
+from typing import TYPE_CHECKING
+
+from lxml import etree
+
+from odoo import api, fields, models
+
+if TYPE_CHECKING:
+    from .se_binding import SeBinding
+    from .se_index import SeIndex
+
+
+SMART_BUTTON = """
+<button class="oe_stat_button"
+       name="open_se_binding"
+       icon="fa-list-ul"
+       type="object">
+       <div class="o_field_widget o_stat_info">
+            <span class="o_stat_value">
+                <i attrs="{'invisible': [
+                    '|',
+                    ('count_se_binding_pending', '>', 0),
+                    ('count_se_binding_error', '>', 0)
+                   ]}"
+                   class="fa fa-thumbs-o-up text-success o_column_title"
+                   aria-hidden="true"> :
+                    <field name="count_se_binding_done"/>
+                </i>
+                <i attrs="{'invisible': [
+                    '|',
+                    ('count_se_binding_pending', '=', 0),
+                    ('count_se_binding_error', '>', 0)
+                   ]}"
+                   class="fa fa-spinner text-warning" aria-hidden="true"> :
+                    <field name="count_se_binding_pending"/>
+                </i>
+                <i attrs="{'invisible': [('count_se_binding_error', '=', 0)]}"
+                   class="fa fa-exclamation-triangle text-danger" aria-hidden="true"> :
+                    <field name="count_se_binding_error"/>
+                </i>
+            </span>
+            <span>Index</span>
+       </div>
+</button>"""
+
+
+class SeIndexableRecord(models.AbstractModel):
+    _name = "se.indexable.record"
+    _description = "Mixin that make record indexable in a search engine"
+    _se_indexable = True
+
+    se_binding_ids = fields.One2many(
+        string="Seacrh Engine Bindings",
+        comodel_name="se.binding",
+        compute="_compute_binding_ids",
+    )
+    count_se_binding_done = fields.Integer(compute="_compute_count_binding")
+    count_se_binding_pending = fields.Integer(compute="_compute_count_binding")
+    count_se_binding_error = fields.Integer(compute="_compute_count_binding")
+
+    def _compute_binding_ids(self) -> None:
+        binding_model = self.env["se.binding"]
+        values = {
+            read["res_id"][0]: read["ids"]
+            for read in binding_model.read_group(
+                domain=[
+                    ("res_model", "=", self._name),
+                    ("res_id", "in", self.ids),
+                ],
+                fields=["ids:array_agg(id)"],
+                groupby=["res_id"],
+            )
+        }
+        for record in self:
+            record.se_binding_ids = binding_model.browse(values.get(record.id, []))
+
+    def _get_count_per_state(self):
+        res = defaultdict(lambda: defaultdict(int))
+        data = self.env["se.binding"].read_group(
+            [
+                ("res_id", "in", self.ids),
+                ("res_model", "=", self._name),
+            ],
+            ["res_id", "state"],
+            groupby=["res_id", "state"],
+            lazy=False,
+        )
+        map_state = {
+            "done": "done",
+            "to_recompute": "pending",
+            "recomputing": "pending",
+            "to_export": "pending",
+            "exporting": "pending",
+            "to_delete": "pending",
+            "deleting": "pending",
+            "invalid_data": "error",
+            "recompute_error": "error",
+        }
+        for item in data:
+            res[item["res_id"]][map_state[item["state"]]] += item["__count"]
+        return res
+
+    def _compute_count_binding(self):
+        res = self._get_count_per_state()
+        for record in self:
+            record.count_se_binding_done = res[record.id]["done"]
+            record.count_se_binding_pending = res[record.id]["pending"]
+            record.count_se_binding_error = res[record.id]["error"]
+
+    def _get_bindings(self, index: SeIndex = None) -> SeBinding:
+        domain = [
+            ("res_model", "=", self._name),
+            ("res_id", "in", self.ids),
+        ]
+        if index:
+            domain.append(("index_id", "=", index.id))
+        return self.env["se.binding"].search(domain)
+
+    def _add_to_index(self, index: SeIndex) -> SeBinding:
+        """Add the record to the index.
+
+        It will create a binding for each record that is not already
+        binded to the index and mark the others to be updated.
+
+        :param index: The index where the record should be added
+        :return: The binding recordset
+        """
+        bindings = self._get_bindings(index)
+        bindings.filtered(lambda s: s.state == "to_delete").write(
+            {"state": "to_recompute"}
+        )
+        if bindings:
+            todo = self - bindings.record
+        else:
+            todo = self
+        vals_list = [
+            {
+                "index_id": index.id,
+                "res_id": record.id,
+                "res_model": self._name,
+            }
+            for record in todo
+        ]
+        return bindings | self.env["se.binding"].create(vals_list)
+
+    def _remove_from_index(self, index: SeIndex) -> None:
+        """Remove the record from the index.
+
+        It will mark the binding to be deleted.
+        Once the data will be removed from the index, the binding will be
+        deleted.
+        """
+        bindings = self._get_bindings(index)
+        bindings.write({"state": "to_delete"})
+
+    def _se_mark_to_update(self, index: SeIndex | None = None) -> None:
+        """Mark the record to be updated in the index."""
+        bindings = self._get_bindings(index)
+        bindings.write({"state": "to_recompute"})
+
+    def unlink(self):
+        bindings = self._get_bindings()
+        bindings.write(
+            {
+                "state": "to_delete",
+                "res_id": False,
+            }
+        )
+        return super().unlink()
+
+    @api.model
+    def _get_view(self, view_id=None, view_type="form", **options):
+        arch, view = super()._get_view(view_id=view_id, view_type=view_type, **options)
+        button_box = arch.xpath("//div[@name='button_box']")
+        if button_box:
+            button_box[0].append(etree.fromstring(SMART_BUTTON))
+        return arch, view
+
+    def open_se_binding(self):
+        action = self.env.ref("connector_search_engine.se_binding_action").read()[0]
+        action["domain"] = [("res_model", "=", self._name), ("res_id", "in", self.ids)]
+        action["context"] = "{'hide_res_model': True}"
+        return action
diff --git a/connector_search_engine/readme/DESCRIPTION.rst b/connector_search_engine/readme/DESCRIPTION.rst
index a7738e08..4fe5c24c 100644
--- a/connector_search_engine/readme/DESCRIPTION.rst
+++ b/connector_search_engine/readme/DESCRIPTION.rst
@@ -1,1 +1,3 @@
-Base module for connecting Odoo with external search engines.
+Base module for connecting Odoo with external search engines. This addon is
+intended to be used as a base for other addons that implement specific search
+engines. It's designed to be easily extensible and modular.
diff --git a/connector_search_engine/readme/INSTALL.rst b/connector_search_engine/readme/INSTALL.rst
new file mode 100644
index 00000000..38557e9f
--- /dev/null
+++ b/connector_search_engine/readme/INSTALL.rst
@@ -0,0 +1,10 @@
+This addon uses the native json python package provided by python. When
+a json for a record is recomputed, the new value is compared to the original
+one to see if an export to the search engine index is needed.  This is
+done by comparing the md5 of the two json strings. This process when done on
+a large number of records can be slow when the json is large and complex. To speed
+up this process you can install the orjson package.
+
+.. code-block:: bash
+
+    pip install orjson
diff --git a/connector_search_engine/readme/USAGE.rst b/connector_search_engine/readme/USAGE.rst
new file mode 100644
index 00000000..cbb5f407
--- /dev/null
+++ b/connector_search_engine/readme/USAGE.rst
@@ -0,0 +1,102 @@
+Overview
+~~~~~~~~
+
+A search engine is a system designed to store information in a way that makes
+it easy to find through search and analytics queries. The main difference
+between a search engine and a database is that a search engine is optimized
+for search and analytics queries, while a database is optimized for
+transactional and relational queries.
+
+This addons is designed around 4 main concepts:
+
+* **The search engine backend** is used to define into Odoo the kind
+  of search engine that will be used to index the data. It's main responsibility
+  is to provide an instance of `odoo.addons.search_engine.tools.adapter.SearchEngineAdapter`
+  that will be used to communicate with the search engine.
+
+* **The search engine index** is used to define into Odoo the index where
+  the data will be indexed. An index is always linked to a search engine backend.
+  The index provides methods to use to manage the lifecycle of the data put into
+  the index for the records of a given model. To do so, it uses:
+
+  * **The SearchEngineAdapter** provided by the backend to communicate with the
+    search engine.
+  * **A ModelSerializer** that is used to transform an odoo record into
+    a dictionary that can be indexed into the search engine.
+  * **A JsonValidator** that is used to validate the data that is to be
+    indexed into the search engine.
+
+  The RecordSerializer and IndexDataValidator are defined on the index itself.
+  The current addon provides a default implementation only for the IndexDataValidator.
+  You can find into the github repository `search-engine <https://github.com:
+  OCA/search-engine/tree/16.0>`_ An implementation of the RecordSerializer based
+  on the jsonifier addon `connector_search_engine_jsonifier`.
+
+* **The search engine indexable record** is a mixin that is used to define
+  the records that can be indexed into a search engine index. The mixin
+  provides methods:
+
+  * To add a record to an index.
+  * To remove a record from an index.
+  * To mark the record into an index (*the search engine bindings*) as to be
+    recomputed (This method should be called when modifications are made on
+    the record that could impact the data that are indexed into the search
+    engine. It will instruct the index that the record must be recomputed and
+    re-indexed).
+
+  It also ensures that when the record is unlinked, it is removed from the indexes
+  it was indexed into.
+
+* **The search engine binding** is a model that represents the link between
+  an index and an indexable odoo record. It give you access to the data
+  that are indexed into the search engine for the record. It's also used to
+  manage the lifecycle of the data into the search engine. When a binding is
+  created, it's marked as to be computed. Once the data are computed, the
+  binding is marked as to be indexed. Once the data are indexed, the binding
+  is marked as indexed. If the linked record is unlinked, the binding is
+  marked as to be removed. Once the data are removed from the search engine,
+  the binding is deleted.
+
+Indexing lifecycle
+~~~~~~~~~~~~~~~~~~
+
+The indexing lifecycle is based on the following steps:
+
+* When a record is added to an index, a binding is created and marked as to be
+  computed.
+* A cron job scheduled every 5 minutes will look for bindings that are to be
+  computed and for each of them will schedule a job to re compute the json data.
+* When the json data is computed, the binding is marked as to be exported if the
+  json is valid and is different from the one that has been computed last time.
+* A cron job scheduled every 5 minutes will ensure the syncing with the search
+  engine. It will:
+
+  * look for bindings that are to be exported and for each of them will schedule
+    a job to export the json data into the search engine. Once exported, the
+    binding is marked as 'done'.
+  * look for bindings that are to be removed and for each of them will schedule
+    a job to remove the data from the search engine. Once removed, the binding
+    is deleted.
+
+To keep in sync the data from your model instance and the data that are indexed
+into the search engine, you should call the method `_se_mark_to_update` on the
+mode instance when you make modifications that could impact the data that are
+indexed into the search engine.
+
+* When the method `_se_mark_to_update` is called, the binding is marked as to be
+  computed.
+* From there, the same process as described above will be used to recompute the
+  data and reindex them into the search engine.
+
+When a model instance is unlinked, the binding is marked as to be removed. From
+there if will be processed by the job syncing the data with the search engine.
+
+.. note::
+
+  In previous versions of this addon, there was no method to mark a record as
+  to be recomputed. As a consequence, all the records were re-computed every day
+  to ensure that the data in the search engine were up to date. This was a
+  performance issue and consumed a lot of resources. If despite this, you want
+  to recompute all the records every day, you can activate the cron jon
+  `Search engine: recompute all index` and deactivate the one named
+  `earch engine: Generate job for recompute binding to recompute per index`.
diff --git a/connector_search_engine/security/ir.model.access.csv b/connector_search_engine/security/ir.model.access.csv
index e23234ff..a1353e15 100644
--- a/connector_search_engine/security/ir.model.access.csv
+++ b/connector_search_engine/security/ir.model.access.csv
@@ -1,4 +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
+access_se_backend,Search Engine Backend connector manager,model_se_backend,group_connector_search_engine_manager,1,1,1,1
+access_se_index,Search Engine Index connector manager,model_se_index,group_connector_search_engine_manager,1,1,1,1
+access_se_binding,Search Engine Binding connector manager,model_se_binding,group_connector_search_engine_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
index 326e4003..0bbc713c 100644
--- a/connector_search_engine/security/se_index_config.xml
+++ b/connector_search_engine/security/se_index_config.xml
@@ -5,7 +5,7 @@
     <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="group_id" ref="group_connector_search_engine_manager" />
         <field name="perm_read" eval="1" />
         <field name="perm_create" eval="1" />
         <field name="perm_write" eval="1" />
diff --git a/connector_search_engine/static/description/icon.png b/connector_search_engine/static/description/icon.png
index 3a0328b5..9368d66d 100644
Binary files a/connector_search_engine/static/description/icon.png 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
index d0c486ff..942072a1 100644
--- a/connector_search_engine/static/description/index.html
+++ b/connector_search_engine/static/description/index.html
@@ -3,7 +3,7 @@
 <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/" />
+<meta name="generator" content="Docutils: http://docutils.sourceforge.net/" />
 <title>Connector Search Engine</title>
 <style type="text/css">
 
@@ -367,27 +367,152 @@ <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><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/16.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-16-0/search-engine-16-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/16.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. This addon is
+intended to be used as a base for other addons that implement specific search
+engines. It’s designed to be easily extensible and modular.</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>
+<li><a class="reference internal" href="#installation" id="id1">Installation</a></li>
+<li><a class="reference internal" href="#usage" id="id2">Usage</a><ul>
+<li><a class="reference internal" href="#overview" id="id3">Overview</a></li>
+<li><a class="reference internal" href="#indexing-lifecycle" id="id4">Indexing lifecycle</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>
+<li><a class="reference internal" href="#known-issues-roadmap" id="id5">Known issues / Roadmap</a></li>
+<li><a class="reference internal" href="#changelog" id="id6">Changelog</a><ul>
+<li><a class="reference internal" href="#x-y-z-yyyy-mm-dd" id="id7">12.0.x.y.z (YYYY-MM-DD)</a></li>
 </ul>
 </li>
+<li><a class="reference internal" href="#bug-tracker" id="id8">Bug Tracker</a></li>
+<li><a class="reference internal" href="#credits" id="id9">Credits</a><ul>
+<li><a class="reference internal" href="#authors" id="id10">Authors</a></li>
+<li><a class="reference internal" href="#contributors" id="id11">Contributors</a></li>
+<li><a class="reference internal" href="#maintainers" id="id12">Maintainers</a></li>
 </ul>
+</li>
+</ul>
+</div>
+<div class="section" id="installation">
+<h1><a class="toc-backref" href="#id1">Installation</a></h1>
+<p>This addon uses the native json python package provided by python. When
+a json for a record is recomputed, the new value is compared to the original
+one to see if an export to the search engine index is needed.  This is
+done by comparing the md5 of the two json strings. This process when done on
+a large number of records can be slow when the json is large and complex. To speed
+up this process you can install the orjson package.</p>
+<pre class="code bash literal-block">
+pip install orjson
+</pre>
+</div>
+<div class="section" id="usage">
+<h1><a class="toc-backref" href="#id2">Usage</a></h1>
+<div class="section" id="overview">
+<h2><a class="toc-backref" href="#id3">Overview</a></h2>
+<p>A search engine is a system designed to store information in a way that makes
+it easy to find through search and analytics queries. The main difference
+between a search engine and a database is that a search engine is optimized
+for search and analytics queries, while a database is optimized for
+transactional and relational queries.</p>
+<p>This addons is designed around 4 main concepts:</p>
+<ul>
+<li><p class="first"><strong>The search engine backend</strong> is used to define into Odoo the kind
+of search engine that will be used to index the data. It’s main responsibility
+is to provide an instance of <cite>odoo.addons.search_engine.tools.adapter.SearchEngineAdapter</cite>
+that will be used to communicate with the search engine.</p>
+</li>
+<li><p class="first"><strong>The search engine index</strong> is used to define into Odoo the index where
+the data will be indexed. An index is always linked to a search engine backend.
+The index provides methods to use to manage the lifecycle of the data put into
+the index for the records of a given model. To do so, it uses:</p>
+<ul class="simple">
+<li><strong>The SearchEngineAdapter</strong> provided by the backend to communicate with the
+search engine.</li>
+<li><strong>A ModelSerializer</strong> that is used to transform an odoo record into
+a dictionary that can be indexed into the search engine.</li>
+<li><strong>A JsonValidator</strong> that is used to validate the data that is to be
+indexed into the search engine.</li>
+</ul>
+<p>The RecordSerializer and IndexDataValidator are defined on the index itself.
+The current addon provides a default implementation only for the IndexDataValidator.
+You can find into the github repository <a class="reference external" href="https://github.com:OCA/search-engine/tree/16.0">search-engine</a> An implementation of the RecordSerializer based
+on the jsonifier addon <cite>connector_search_engine_jsonifier</cite>.</p>
+</li>
+<li><p class="first"><strong>The search engine indexable record</strong> is a mixin that is used to define
+the records that can be indexed into a search engine index. The mixin
+provides methods:</p>
+<ul class="simple">
+<li>To add a record to an index.</li>
+<li>To remove a record from an index.</li>
+<li>To mark the record into an index (<em>the search engine bindings</em>) as to be
+recomputed (This method should be called when modifications are made on
+the record that could impact the data that are indexed into the search
+engine. It will instruct the index that the record must be recomputed and
+re-indexed).</li>
+</ul>
+<p>It also ensures that when the record is unlinked, it is removed from the indexes
+it was indexed into.</p>
+</li>
+<li><p class="first"><strong>The search engine binding</strong> is a model that represents the link between
+an index and an indexable odoo record. It give you access to the data
+that are indexed into the search engine for the record. It’s also used to
+manage the lifecycle of the data into the search engine. When a binding is
+created, it’s marked as to be computed. Once the data are computed, the
+binding is marked as to be indexed. Once the data are indexed, the binding
+is marked as indexed. If the linked record is unlinked, the binding is
+marked as to be removed. Once the data are removed from the search engine,
+the binding is deleted.</p>
+</li>
+</ul>
+</div>
+<div class="section" id="indexing-lifecycle">
+<h2><a class="toc-backref" href="#id4">Indexing lifecycle</a></h2>
+<p>The indexing lifecycle is based on the following steps:</p>
+<ul class="simple">
+<li>When a record is added to an index, a binding is created and marked as to be
+computed.</li>
+<li>A cron job scheduled every 5 minutes will look for bindings that are to be
+computed and for each of them will schedule a job to re compute the json data.</li>
+<li>When the json data is computed, the binding is marked as to be exported if the
+json is valid and is different from the one that has been computed last time.</li>
+<li>A cron job scheduled every 5 minutes will ensure the syncing with the search
+engine. It will:<ul>
+<li>look for bindings that are to be exported and for each of them will schedule
+a job to export the json data into the search engine. Once exported, the
+binding is marked as ‘done’.</li>
+<li>look for bindings that are to be removed and for each of them will schedule
+a job to remove the data from the search engine. Once removed, the binding
+is deleted.</li>
+</ul>
+</li>
+</ul>
+<p>To keep in sync the data from your model instance and the data that are indexed
+into the search engine, you should call the method <cite>_se_mark_to_update</cite> on the
+mode instance when you make modifications that could impact the data that are
+indexed into the search engine.</p>
+<ul class="simple">
+<li>When the method <cite>_se_mark_to_update</cite> is called, the binding is marked as to be
+computed.</li>
+<li>From there, the same process as described above will be used to recompute the
+data and reindex them into the search engine.</li>
+</ul>
+<p>When a model instance is unlinked, the binding is marked as to be removed. From
+there if will be processed by the job syncing the data with the search engine.</p>
+<div class="admonition note">
+<p class="first admonition-title">Note</p>
+<p class="last">In previous versions of this addon, there was no method to mark a record as
+to be recomputed. As a consequence, all the records were re-computed every day
+to ensure that the data in the search engine were up to date. This was a
+performance issue and consumed a lot of resources. If despite this, you want
+to recompute all the records every day, you can activate the cron jon
+<cite>Search engine: recompute all index</cite> and deactivate the one named
+<cite>earch engine: Generate job for recompute binding to recompute per index</cite>.</p>
+</div>
+</div>
 </div>
 <div class="section" id="known-issues-roadmap">
-<h1><a class="toc-backref" href="#id1">Known issues / Roadmap</a></h1>
+<h1><a class="toc-backref" href="#id5">Known issues / Roadmap</a></h1>
 <ul class="simple">
 <li>Implement generic trigger for binding
 based on ir.export linked to the index
@@ -396,24 +521,24 @@ <h1><a class="toc-backref" href="#id1">Known issues / Roadmap</a></h1>
 </ul>
 </div>
 <div class="section" id="changelog">
-<h1><a class="toc-backref" href="#id2">Changelog</a></h1>
+<h1><a class="toc-backref" href="#id6">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>
+<h2><a class="toc-backref" href="#id7">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>
+<h1><a class="toc-backref" href="#id8">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>
+<a class="reference external" href="https://github.com/OCA/search-engine/issues/new?body=module:%20connector_search_engine%0Aversion:%2016.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>
+<h1><a class="toc-backref" href="#id9">Credits</a></h1>
 <div class="section" id="authors">
-<h2><a class="toc-backref" href="#id6">Authors</a></h2>
+<h2><a class="toc-backref" href="#id10">Authors</a></h2>
 <ul class="simple">
 <li>Akretion</li>
 <li>ACSONE SA/NV</li>
@@ -421,7 +546,7 @@ <h2><a class="toc-backref" href="#id6">Authors</a></h2>
 </ul>
 </div>
 <div class="section" id="contributors">
-<h2><a class="toc-backref" href="#id7">Contributors</a></h2>
+<h2><a class="toc-backref" href="#id11">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>
@@ -430,13 +555,13 @@ <h2><a class="toc-backref" href="#id7">Contributors</a></h2>
 </ul>
 </div>
 <div class="section" id="maintainers">
-<h2><a class="toc-backref" href="#id8">Maintainers</a></h2>
+<h2><a class="toc-backref" href="#id12">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>This module is part of the <a class="reference external" href="https://github.com/OCA/search-engine/tree/16.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>
diff --git a/connector_search_engine/tools/__init__.py b/connector_search_engine/tools/__init__.py
new file mode 100644
index 00000000..6f387fdb
--- /dev/null
+++ b/connector_search_engine/tools/__init__.py
@@ -0,0 +1,2 @@
+from . import adapter
+from . import validator
diff --git a/connector_search_engine/tools/adapter.py b/connector_search_engine/tools/adapter.py
new file mode 100644
index 00000000..6efb72f7
--- /dev/null
+++ b/connector_search_engine/tools/adapter.py
@@ -0,0 +1,65 @@
+# 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 __future__ import annotations
+
+from abc import ABC, abstractmethod
+from typing import TYPE_CHECKING, Any, Iterator
+
+if TYPE_CHECKING:
+    from ..models.backend import SearchEngineBackend
+    from ..models.index import SearchEngineIndex
+
+
+class SearchEngineAdapter(ABC):
+    def __init__(
+        self, backend_record: SearchEngineBackend, index_record: SearchEngineIndex
+    ):
+        super().__init__()
+        self.backend_record = backend_record
+        self.index_record = index_record
+
+    @abstractmethod
+    def index(self, datas: list[dict[str, Any]]) -> None:
+        """Index the data into the index on the search engine."""
+        ...
+
+    @abstractmethod
+    def delete(self, binding_ids: list[int]) -> None:
+        """Delete the data from the index on the search engine.
+
+        The binding_ids are the ids of the record in the index.
+        """
+        ...
+
+    @abstractmethod
+    def clear(self) -> None:
+        """Clear the index content on the search engine."""
+        ...
+
+    @abstractmethod
+    def each(self) -> Iterator[dict[str, Any]]:
+        """Return an iterator on the index content."""
+        ...
+
+    def reindex(self) -> None:
+        """Reindex the index on the search engine side."""
+        self.clear()
+        self.index(self._get_index_data())
+
+    def settings(self, force=False) -> None:
+        """Update the settings of the index on the search engine side."""
+
+    def test_connection(self) -> None:
+        """Test the connection to the search engine.
+
+        It should raise an exception if the connection is not working.
+        """
+
+    def _get_odoo_id_from_index_data(self, record: dict[str, Any]) -> int:
+        """Return the odoo id from data stored into the index.
+
+        Whatever the data stored into the index, it should be possible to
+        retrieve the odoo id as an integer.
+        """
+        return int(record["id"])
diff --git a/connector_search_engine/tools/json_comparer.py b/connector_search_engine/tools/json_comparer.py
new file mode 100644
index 00000000..71597c2a
--- /dev/null
+++ b/connector_search_engine/tools/json_comparer.py
@@ -0,0 +1,23 @@
+# copyright: 2023 ACSONE SA/NV (https://www.acsone.eu)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+import hashlib
+
+ORJSON = False
+try:
+    import orjson as json
+
+    ORJSON = True
+except ImportError:
+    import json
+
+
+def compare_json(a: dict, b: dict) -> bool:
+    """Compare 2 dictionaries and return False if they are different."""
+    if ORJSON:
+        a_bytes = json.dumps(a, option=json.OPT_SORT_KEYS)
+        b_bytes = json.dumps(b, option=json.OPT_SORT_KEYS)
+    else:
+        a_bytes = json.dumps(a, sort_keys=True).encode()
+        b_bytes = json.dumps(b, sort_keys=True).encode()
+    return hashlib.sha256(a_bytes).hexdigest() == hashlib.sha256(b_bytes).hexdigest()
diff --git a/connector_search_engine/tools/serializer.py b/connector_search_engine/tools/serializer.py
new file mode 100644
index 00000000..47279f32
--- /dev/null
+++ b/connector_search_engine/tools/serializer.py
@@ -0,0 +1,18 @@
+# copyright: 2023 ACSONE SA/NV (https://www.acsone.eu)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from abc import ABC, abstractmethod
+
+from odoo.models import Model
+
+
+class ModelSerializer(ABC):
+    """Base serializer for the search engine."""
+
+    @abstractmethod
+    def serialize(self, record: Model) -> dict:
+        """Serialize the record into a dict.
+
+        The dict will be sent to the search engine.
+        """
+        ...
diff --git a/connector_search_engine/tools/validator.py b/connector_search_engine/tools/validator.py
new file mode 100644
index 00000000..4fe9471e
--- /dev/null
+++ b/connector_search_engine/tools/validator.py
@@ -0,0 +1,31 @@
+# Copyright 2023 Akretion (https://www.akretion.com).
+# @author Sébastien BEAU <sebastien.beau@akretion.com>
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from abc import ABC, abstractmethod
+
+from odoo import _
+from odoo.exceptions import ValidationError
+
+
+class JsonValidator(ABC):
+    """Base validator for the search engine."""
+
+    @abstractmethod
+    def validate(self, data) -> None:
+        """Validate the data before sending it to the search engine.
+
+        It should raise an ValidationError if the data is not valid.
+        """
+        ...
+
+
+class DefaultJsonValidator(JsonValidator):
+    """Default validator for the search engine.
+
+    It will check that the key 'id' is present in the data.
+    """
+
+    def validate(self, data) -> None:
+        if not data.get("id"):
+            raise ValidationError(_("The key 'id' is missing in the data"))
diff --git a/connector_search_engine/views/se_backend.xml b/connector_search_engine/views/se_backend.xml
index 4c600ef2..84cf65f0 100644
--- a/connector_search_engine/views/se_backend.xml
+++ b/connector_search_engine/views/se_backend.xml
@@ -3,26 +3,34 @@
      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">
+            <form>
+                <header>
+                    <button
+                        name="action_test_connection"
+                        type="object"
+                        string="Test Connection"
+                        icon="fa-television"
+                    />
+                </header>
                 <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 name="se-main">
+                            <field name="tech_name" />
+                            <field name="index_prefix_name" />
+                            <field name="backend_type" />
                         </group>
                     </group>
                     <group name="index" string="Index">
                         <field
                             name="index_ids"
                             nolabel="1"
+                            colspan="2"
                             context="{
                                 'tree_view_ref': 'connector_search_engine.se_index_tree_no_backend_view',
                                 'from_backend_view': True
@@ -39,13 +47,12 @@
         <field name="arch" type="xml">
             <tree create="false">
                 <field name="tech_name" />
-                <field name="specific_backend" />
-                <field name="specific_model" />
+                <field name="backend_type" />
             </tree>
         </field>
     </record>
     <record model="ir.actions.act_window" id="se_backend_act_window">
-        <field name="name">Search Engine Backend</field>
+        <field name="name">Search Engine</field>
         <field name="res_model">se.backend</field>
         <field name="view_mode">tree,form</field>
         <field name="domain">[]</field>
diff --git a/connector_search_engine/views/se_binding_view.xml b/connector_search_engine/views/se_binding_view.xml
new file mode 100644
index 00000000..777bfe95
--- /dev/null
+++ b/connector_search_engine/views/se_binding_view.xml
@@ -0,0 +1,95 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<odoo>
+
+    <record id="se_binding_view_tree" model="ir.ui.view">
+        <field name="model">se.binding</field>
+        <field name="arch" type="xml">
+            <tree
+                decoration-danger="state in ('invalid_data', 'recompute_error')"
+                decoration-warning="state not in ('invalid_data', 'recompute_error', 'done')"
+            >
+                <field name="backend_id" />
+                <field name="index_id" />
+                <field name="state" />
+                <field name="date_recomputed" />
+                <field name="date_synchronized" />
+                <field name="res_model" invisible="context.get('hide_res_model')" />
+                <field name="res_id" invisible="context.get('hide_res_model')" />
+            </tree>
+        </field>
+    </record>
+
+    <record id="se_binding_view_form" model="ir.ui.view">
+        <field name="model">se.binding</field>
+        <field name="arch" type="xml">
+            <form string="Binding">
+                <group>
+                    <field name="backend_id" />
+                    <field name="index_id" />
+                    <field name="state" />
+                    <field name="date_recomputed" />
+                    <field name="date_synchronized" />
+                    <field name="res_model" />
+                    <field name="res_id" />
+                </group>
+                <group string="Error" attrs="{'invisible': [('error', '=', False)]}">
+                    <field name="error" nolabel="1" />
+                </group>
+                <group string="Data">
+                    <field name="data_display" nolabel="1" />
+                </group>
+            </form>
+        </field>
+    </record>
+
+    <record id="se_binding_view_search" model="ir.ui.view">
+        <field name="model">se.binding</field>
+        <field name="arch" type="xml">
+            <search string="Binding">
+                <field name="backend_id" />
+                <field name="index_id" />
+                <field name="state" />
+                <field name="res_model" />
+                    <group expand="0" string="Group By...">
+                        <filter
+                        string="Backend"
+                        name="groupby_backend"
+                        domain="[]"
+                        context="{'group_by':'backend_id'}"
+                    />
+                        <filter
+                        string="Index"
+                        name="groupby_index"
+                        domain="[]"
+                        context="{'group_by':'index_id'}"
+                    />
+                        <filter
+                        string="State"
+                        name="groupby_state"
+                        domain="[]"
+                        context="{'group_by':'state'}"
+                    />
+                        <filter
+                        string="Model"
+                        name="groupby_model"
+                        domain="[]"
+                        context="{'group_by':'res_model'}"
+                    />
+
+                    </group>
+
+            </search>
+        </field>
+    </record>
+
+    <record model="ir.actions.act_window" id="se_binding_action">
+        <field name="name">Binding</field>
+        <field name="type">ir.actions.act_window</field>
+        <field name="res_model">se.binding</field>
+        <field name="view_mode">tree,form</field>
+        <field name="search_view_id" ref="se_binding_view_search" />
+        <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
index 62e16b58..de68dc6c 100644
--- a/connector_search_engine/views/se_index.xml
+++ b/connector_search_engine/views/se_index.xml
@@ -2,11 +2,11 @@
 <!-- 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 string="Index" editable="bottom">
+            <tree editable="bottom">
                 <field name="custom_tech_name" optional="hide" />
                 <field
                     name="backend_id"
@@ -15,49 +15,15 @@
                 <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="serializer_type" required="True" />
                 <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"
-                />
+                <field name="batch_recomputing_size" />
+                <field name="batch_exporting_size" />
             </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>
@@ -68,13 +34,13 @@
                 <group expand="0" string="Group By...">
                     <filter
                         string="Backend"
-                        name="backend"
+                        name="groupby_backend"
                         domain="[]"
                         context="{'group_by':'backend_id'}"
                     />
                     <filter
                         string="Model"
-                        name="model"
+                        name="groupby_model"
                         domain="[]"
                         context="{'group_by':'model_id'}"
                     />
@@ -82,11 +48,123 @@
             </search>
         </field>
     </record>
+
+    <record model="ir.ui.view" id="se_index_kanban_view">
+    <field name="model">se.index</field>
+    <field name="arch" type="xml">
+        <kanban
+                create="false"
+                quick_create="false"
+                group_delete="false"
+                group_create="false"
+                records_draggable="false"
+            >
+            <field name="name" />
+            <field name="model_id" />
+            <field name="backend_id" />
+            <field name="color" />
+            <templates>
+                <t t-name="kanban-box">
+                    <div
+                            t-attf-class="{{!selection_mode ? 'oe_kanban_color_' + kanban_getcolor(record.color.raw_value) : ''}} oe_kanban_card oe_kanban_global_click"
+                        >
+                         <div class="oe_kanban_content">
+                             <div class="o_kanban_record_top">
+                                 <div class="o_kanban_record_headings">
+                                     <strong class="o_kanban_record_title">
+                                         <field name="model_id" />
+                                     </strong>
+                                     <p><field name="lang_id" /></p>
+                                 </div>
+                                 <div
+                                        class="o_dropdown_kanban dropdown"
+                                        t-if="!selection_mode"
+                                        groups="base.group_user"
+                                    >
+                                     <a
+                                            role="button"
+                                            class="dropdown-toggle o-no-caret btn"
+                                            data-bs-toggle="dropdown"
+                                            data-bs-display="static"
+                                            href="#"
+                                            aria-label="Dropdown menu"
+                                            title="Dropdown menu"
+                                        >
+                                         <span class="fa fa-ellipsis-v" />
+                                     </a>
+                                     <div class="dropdown-menu" role="menu">
+                                            <a
+                                                name="force_recompute_all_binding"
+                                                role="menuitem"
+                                                class="dropdown-item"
+                                                help="Force to recompute all binding of the index and set them to the state 'To update'"
+                                                icon="fa-gears"
+                                                type="object"
+                                            >Recompute all binding</a>
+                                            <a
+                                                name="force_batch_sync"
+                                                role="menuitem"
+                                                class="dropdown-item"
+                                                help="Force the export of all binding"
+                                                icon="fa-upload"
+                                                type="object"
+                                            >Export all binding</a>
+                                            <a
+                                                name="clear_index"
+                                                role="menuitem"
+                                                class="dropdown-item"
+                                                help="Clear the index in the search engine"
+                                                icon="fa-times"
+                                                type="object"
+                                                groups="connector_search_engine.group_connector_search_engine_manager"
+                                            >Clear index</a>
+                                            <a
+                                                name="reindex"
+                                                role="menuitem"
+                                                class="dropdown-item"
+                                                help="Reindex records according to the current config"
+                                                icon="fa-refresh"
+                                                type="object"
+                                                groups="connector_search_engine.group_connector_search_engine_manager"
+                                            >Reindex</a>
+
+                                     </div>
+                                 </div>
+                             </div>
+                             <div class="o_kanban_record_body">
+                                 <div>
+                                    <i
+                                            class="fa fa-thumbs-o-up text-success o_column_title"
+                                            aria-hidden="true"
+                                        > : <field name="count_done" /></i>
+                                 </div>
+                                 <div t-if="record.count_pending.raw_value">
+                                 <i
+                                            class="fa fa-spinner text-warning"
+                                            aria-hidden="true"
+                                        > : <field name="count_pending" /></i>
+                                 </div>
+                                 <div t-if="record.count_error.raw_value">
+                                 <i
+                                            class="fa fa-exclamation-triangle text-danger"
+                                            aria-hidden="true"
+                                        > : <field name="count_error" /></i>
+                                 </div>
+                             </div>
+                         </div>
+                     </div>
+                 </t>
+                 </templates>
+             </kanban>
+         </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="view_mode">kanban,tree</field>
         <field name="domain">[]</field>
-        <field name="context">{'default_group_by_backend': 1}</field>
+        <field name="context">{'search_default_groupby_backend': 1}</field>
     </record>
 </odoo>
diff --git a/connector_search_engine/views/se_menu.xml b/connector_search_engine/views/se_menu.xml
index ce1a9450..7f8e540f 100644
--- a/connector_search_engine/views/se_menu.xml
+++ b/connector_search_engine/views/se_menu.xml
@@ -2,27 +2,44 @@
 <odoo>
     <menuitem
         id="se_root_menu"
-        parent="connector.menu_connector_root"
-        name="Search Engine Connector"
+        name="Search Engine"
+        web_icon="connector_search_engine,static/description/icon.png"
         sequence="10"
-        groups="connector.group_connector_manager"
-    />
-    <menuitem
-        id="se_backend_menu"
-        name="Backends"
-        parent="se_root_menu"
-        action="se_backend_act_window"
+        groups="group_connector_search_engine_manager"
     />
     <menuitem
         id="se_index_menu"
         name="Indexes"
         parent="se_root_menu"
         action="se_index_act_window"
+        sequence="10"
+    />
+
+    <menuitem
+        id="se_binding_menu"
+        parent="se_root_menu"
+        sequence="20"
+        action="se_binding_action"
+    />
+
+    <menuitem
+        id="se_root_config"
+        name="Configuration"
+        parent="se_root_menu"
+        sequence="100"
+    />
+    <menuitem
+        id="se_backend_menu"
+        name="Backends"
+        parent="se_root_config"
+        action="se_backend_act_window"
+        sequence="10"
     />
     <menuitem
         id="se_index_config_menu"
         name="Index configurations"
-        parent="se_root_menu"
+        parent="se_root_config"
         action="se_index_config_act_window"
+        sequence="20"
     />
 </odoo>
diff --git a/requirements.txt b/requirements.txt
index 9cd16292..6f3f1c6f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,1 +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/common.py b/connector_search_engine/tests/common.py
index d75a59cb..3d9f9a04 100644
--- a/connector_search_engine/tests/common.py
+++ b/connector_search_engine/tests/common.py
@@ -6,11 +6,7 @@
 
 from odoo import tools
 from odoo.modules.module import get_resource_path
-
-from odoo.addons.component.tests.common import SavepointComponentCase
-
-# mute `test_queue_job_no_delay` logging
-logging.getLogger("odoo.addons.queue_job.models.base").setLevel("CRITICAL")
+from odoo.tests.common import TransactionCase
 
 
 def load_xml(env, module, filepath):
@@ -25,7 +21,7 @@ def load_xml(env, module, filepath):
     )
 
 
-class TestSeBackendCaseBase(SavepointComponentCase):
+class TestSeBackendCaseBase(TransactionCase):
     @classmethod
     def setUpClass(cls):
         super().setUpClass()
@@ -36,7 +32,6 @@ def setUpClass(cls):
                 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"):
@@ -45,3 +40,18 @@ def _load_fixture(cls, fixture, module="connector_search_engine"):
     @staticmethod
     def parse_path(url):
         return urlparse.urlparse(url).path
+
+    def setUp(self):
+        super(TestSeBackendCaseBase, self).setUp()
+        loggers = ["odoo.addons.queue_job.delay"]
+        for logger in loggers:
+            logging.getLogger(logger).addFilter(self)
+
+        # pylint: disable=unused-variable
+        @self.addCleanup
+        def un_mute_logger():
+            for logger_ in loggers:
+                logging.getLogger(logger_).removeFilter(self)
+
+    def filter(self, record):
+        return 0
diff --git a/connector_search_engine/tests/fixtures/ir_exports_test.xml b/connector_search_engine/tests/fixtures/ir_exports_test.xml
deleted file mode 100644
index 026910f7..00000000
--- a/connector_search_engine/tests/fixtures/ir_exports_test.xml
+++ /dev/null
@@ -1,69 +0,0 @@
-<?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
index 4546103c..39e602ea 100644
--- a/connector_search_engine/tests/models.py
+++ b/connector_search_engine/tests/models.py
@@ -4,48 +4,34 @@
 
 from odoo import fields, models
 
-from odoo.addons.component.core import Component
+from ..tools.adapter import SearchEngineAdapter
 
 
-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)
+class FakeSeAdapter(SearchEngineAdapter):
+    def __init__(self, *args):
+        super().__init__(*args)
         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)
+            dict(index=self.index_record, method="index", args=data)
         )
 
     def delete(self, binding_ids):
         self._mocked_calls.append(
-            dict(work_ctx=self.work.__dict__, method="delete", args=binding_ids)
+            dict(index=self.index_record, method="delete", args=binding_ids)
         )
 
     def clear(self):
         self._mocked_calls.append(
-            dict(work_ctx=self.work.__dict__, method="clear", args=None)
+            dict(index=self.index_record, method="clear", args=None)
         )
 
     def each(self):
         self._mocked_calls.append(
-            dict(work_ctx=self.work.__dict__, method="each", args=None)
+            dict(index=self.index_record, method="each", args=None)
         )
         return [{"id": 42}]
 
@@ -56,7 +42,7 @@ def mocked_calls(cls):
 
         Usage:
 
-            with SeAdapterFake.mocked_calls() as calls:
+            with FakeSeAdapter.mocked_calls() as calls:
                 # do something, then
                 self.assertEqual(calls[0]['method'], 'clear')
                 # do more
@@ -66,41 +52,39 @@ def mocked_calls(cls):
         cls._mocked_calls = []
 
 
-# Fake partner binding
+class FakeSerializer:
+    def serialize(self, record):
+        return {"name": record.name, "id": record.id}
 
 
-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
+class SeBackend(models.Model):
+    _inherit = "se.backend"
 
-    # 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",
+    backend_type = fields.Selection(
+        selection_add=[("fake", "Fake")], ondelete={"fake": "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
+    def _get_adapter_class(self):
+        if self.backend_type == "fake":
+            return FakeSeAdapter
+        else:
+            return super()._get_adapter_class()
 
 
-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
+class SeIndex(models.Model):
+    _inherit = "se.index"
+
+    serializer_type = fields.Selection(
+        selection_add=[("fake", "Fake")], ondelete={"fake": "cascade"}
     )
+
+    def _get_serializer(self):
+        if self.serializer_type == "fake":
+            return FakeSerializer()
+        else:
+            return super()._get_serializer()
+
+
+class ResPartner(models.Model):
+    _name = "res.partner"
+    _inherit = ["res.partner", "se.indexable.record"]
diff --git a/connector_search_engine/tests/test_all.py b/connector_search_engine/tests/test_all.py
index 80cb2dfa..526c2c46 100644
--- a/connector_search_engine/tests/test_all.py
+++ b/connector_search_engine/tests/test_all.py
@@ -1,12 +1,16 @@
 # Copyright 2018 Simone Orsi - Camptocamp SA
 # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
 
-import mock
+from unittest import mock
+
 from odoo_test_helper import FakeModelLoader
 
 from odoo.tests.common import Form
 from odoo.tools import mute_logger
 
+from odoo.addons.queue_job.job import identity_exact
+from odoo.addons.queue_job.tests.common import trap_jobs
+
 from .common import TestSeBackendCaseBase
 
 
@@ -18,25 +22,19 @@ def setUpClass(cls):
         cls.loader = FakeModelLoader(cls.env, cls.__module__)
         cls.loader.backup_registry()
         from .models import (
-            BindingResPartnerFake,
-            ResPartnerFake,
-            SeAdapterFake,
-            SeBackendFake,
+            FakeSeAdapter,
+            FakeSerializer,
+            ResPartner,
+            SeBackend,
+            SeIndex,
         )
 
-        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"
-        )
+        cls.loader.update_registry((ResPartner, SeBackend, SeIndex))
+        cls.binding_model = cls.env["se.binding"]
+        cls.se_index_model = cls.env["se.index"]
+
+        cls.se_adapter = FakeSeAdapter
+        cls.model_serializer = FakeSerializer
 
     @classmethod
     def tearDownClass(cls):
@@ -50,10 +48,10 @@ def _prepare_index_values(cls, backend=None):
             "name": "Partner Index",
             "backend_id": backend.id,
             "model_id": cls.env["ir.model"]
-            .search([("model", "=", "res.partner.binding.fake")], limit=1)
+            .search([("model", "=", "res.partner")], limit=1)
             .id,
             "lang_id": cls.env.ref("base.lang_en").id,
-            "exporter_id": cls.exporter.id,
+            "serializer_type": "fake",
         }
 
     @classmethod
@@ -62,7 +60,7 @@ def setup_records(cls, backend=None):
         # 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(
+        cls.partner = cls.env["res.partner"].create(
             {
                 "name": "Marty McFly",
                 "country_id": cls.env.ref("base.us").id,
@@ -70,22 +68,21 @@ def setup_records(cls, backend=None):
                 "child_ids": [
                     (0, 0, {"name": "Doc Brown", "email": "docbrown@future.com"})
                 ],
-                "index_id": cls.se_index.id,
             }
         )
-        cls.partner = cls.partner_binding.record_id
+        cls.partner_binding = cls.partner._add_to_index(cls.se_index)
+
+        cls.partner_expected = {"id": cls.partner.id, "name": cls.partner.name}
 
 
 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_model = cls.env["se.backend"]
+        cls.backend = cls.backend_model.create(
+            {"name": "Fake SE", "tech_name": "fake_se", "backend_type": "fake"}
         )
-        cls.backend = cls.backend_specific.se_backend_id
         cls.setup_records()
 
 
@@ -94,33 +91,16 @@ class TestBindingIndex(TestBindingIndexBaseFake):
     def setUpClass(cls):
         super().setUpClass()
 
+    def assert_index_called(self, calls, action, ids):
+        self.assertEqual(
+            calls, [{"index": self.se_index, "method": action, "args": ids}]
+        )
+
     # 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!"
@@ -135,28 +115,35 @@ def test_backend_name(self):
         self.assertEqual(form.index_prefix_name, "better_prefix")
 
     def test_backend_create_tech_defaults(self):
-        b1 = self.fake_backend_model.create({"name": "Fake 1"})
+        b1 = self.backend_model.create({"name": "Fake 1", "backend_type": "fake"})
         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"})
+        b2 = self.backend_model.create(
+            {"name": "Fake 2", "tech_name": "test2", "backend_type": "fake"}
+        )
         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"}
+        b3 = self.backend_model.create(
+            {
+                "name": "Fake 3",
+                "tech_name": "test3",
+                "index_prefix_name": "baz",
+                "backend_type": "fake",
+            }
         )
         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")
+        self.assertEqual(self.se_index.name, "fake_se_contact_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.invalidate_recordset()
+        self.assertEqual(self.se_index.name, "foo_baz_contact_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")
+        self.se_index.invalidate_recordset()
+        self.assertEqual(self.se_index.name, "foo_baz_contact")
 
     def test_index_custom_name(self):
         self.se_index.custom_tech_name = "something meaningful for me"
@@ -164,255 +151,206 @@ def test_index_custom_name(self):
             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)
+        self.assertIn("res.partner", models)
+
+    def test_index_record(self):
+        self.assertEqual(self.partner_binding.state, "to_recompute")
+        self.assertFalse(self.partner_binding.date_recomputed)
+
+    def test_unindex_record(self):
+        self.partner._remove_from_index(self.se_index)
+        self.assertEqual(self.partner_binding.state, "to_delete")
+
+    def test_unlink_record(self):
+        self.partner.unlink()
+        self.assertEqual(self.partner_binding.state, "to_delete")
+
+    def test_recompute_one_record(self):
+        self.partner_binding.recompute_json()
+        self.assertEqual(self.partner_binding.state, "to_export")
+        self.assertEqual(self.partner_binding.get_export_data(), self.partner_expected)
+        self.assertTrue(self.partner_binding.date_recomputed)
 
     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)
+        self.assertEqual(self.partner_binding.get_export_data(), self.partner_expected)
+        self.assertEqual(self.partner_binding.state, "to_export")
+        self.assertTrue(self.partner_binding.date_recomputed)
 
     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_force_batch_sync_with_not_exportable_binding(self):
+        for state in ("to_recompute", "recomputing", "invalid_data", "recompute_error"):
+            self.partner_binding.state = state
+            with self.se_adapter.mocked_calls() as calls:
+                self.se_index.force_batch_sync()
+                self.assertEqual(calls, [])
+                self.assertEqual(self.partner_binding.state, state)
+
+    def test_force_batch_sync_with_exportable_binding(self):
+        self.partner_binding.recompute_json()
+        for state in ("done", "to_export", "exporting"):
+            self.partner_binding.state = state
+            with self.se_adapter.mocked_calls() as calls:
+                self.se_index.force_batch_sync()
+                self.assert_index_called(calls, "index", [self.partner_binding.data])
+                self.assertEqual(self.partner_binding.state, "done")
+
+    def test_force_batch_sync_with_to_delete_binding(self):
+        self.partner_binding.state = "to_delete"
+        with self.se_adapter.mocked_calls() as calls:
+            self.se_index.force_batch_sync()
+            self.assert_index_called(calls, "delete", [self.partner.id])
+            self.assertFalse(self.partner_binding.exists())
+
+    def test_force_batch_sync_with_deleting_binding(self):
+        self.partner_binding.state = "deleting"
+        with self.se_adapter.mocked_calls() as calls:
+            self.se_index.force_batch_sync()
+            self.assert_index_called(calls, "delete", [self.partner.id])
+            self.assertFalse(self.partner_binding.exists())
+
+    def test_batch_sync_not_exportable(self):
+        for state in (
+            "to_recompute",
+            "recomputing",
+            "invalid_data",
+            "done",
+            "deleting",
+        ):
+            self.partner_binding.state = state
+            with self.se_adapter.mocked_calls() as calls:
+                self.se_index.batch_sync()
+                self.assertEqual(self.partner_binding.state, state)
+                self.assertEqual(calls, [])
+
+    def test_batch_sync_deletable(self):
+        self.partner_binding.state = "to_delete"
+        with self.se_adapter.mocked_calls() as calls:
+            self.se_index.batch_sync()
+            self.assert_index_called(calls, "delete", [self.partner.id])
+            self.assertFalse(self.partner_binding.exists())
+
+    def test_batch_sync_exportable(self):
+        self.partner_binding.state = "to_export"
+        with self.se_adapter.mocked_calls() as calls:
+            self.se_index.batch_sync()
+            self.assert_index_called(calls, "index", [self.partner_binding.data])
+            self.assertEqual(self.partner_binding.state, "done")
+
+    @mute_logger("odoo.addons.connector_search_engine.models.se_binding")
+    def test_missing_record_to_recompute(self):
+        # following case should not occure (as unlink will change the binding state)
+        # but in case of weird action like sql delete we want to make it stronger
+        self.partner_binding.state = "to_recompute"
+        self.partner_binding.res_id = 999999999
+        self.partner_binding.recompute_json()
+        self.assertEqual(self.partner_binding.state, "to_delete")
 
     def test_clear_index(self):
-        with self.se_adapter_fake.mocked_calls() as calls:
+        with self.se_adapter.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]["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):
+    def test_recompute_json_with_error_solve(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.state = "invalid_data"
+        self.partner_binding.error = "Something wrong with data"
         self.partner_binding.recompute_json()
-        self.assertEqual(self.partner_binding.data["id"], "Marty McFly")
+        self.assertEqual(self.partner_binding.state, "to_export")
+        self.assertEqual(self.partner_binding.error, "")
 
     @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))
+    def test_recompute_json_missing_record_key(self):
+        with mock.patch.object(
+            self.model_serializer, "serialize", return_value={"name": "Foo"}
+        ):
+            self.partner_binding.recompute_json()
+            self.assertEqual(self.partner_binding.state, "invalid_data")
+            self.assertEqual(
+                self.partner_binding.error,
+                "The key 'id' is missing in the data",
+            )
+
+    def test_life_cycle(self):
+        self.partner_binding.state = "done"
+        # we mark the partner to update
+        self.partner._se_mark_to_update()
+        self.assertEqual(self.partner_binding.state, "to_recompute")
+        # we launch the job scheduled every 5 minutes to recompute
+        # bindings to recompute
+        with trap_jobs() as trap:
+            self.se_index_model.generate_batch_recompute_per_index()
+            trap.assert_jobs_count(2)
+            trap.assert_enqueued_job(
+                self.se_index.batch_recompute,
+                args=(False,),
+                kwargs={},
+                properties=dict(
+                    identity_key=identity_exact,
+                ),
+            )
+            trap.assert_enqueued_job(
+                self.partner_binding.recompute_json,
+                args=(),
+                kwargs={},
+            )
+        # the binding is now in to_export
+        self.assertEqual(self.partner_binding.state, "to_export")
+
+        # we launch the job scheduled every 5 minutes to sync bindings
+        # with the search engine
+        # In our case, the binding is in to_export, so it should be exported
+        with trap_jobs() as trap:
+            self.se_index_model.generate_batch_sync_per_index()
+            trap.assert_jobs_count(2)
+            trap.assert_enqueued_job(
+                self.se_index.batch_sync,
+                args=(False,),
+                kwargs={},
+                properties=dict(
+                    identity_key=identity_exact,
+                ),
+            )
+            trap.assert_enqueued_job(
+                self.partner_binding.export_record,
+                args=(),
+                kwargs={},
+            )
+
+        # the binding is now in done
+        self.assertEqual(self.partner_binding.state, "done")
+
+        # if we unlink the partner, the binding should be deleted on the next
+        # batch sync
+        self.partner.unlink()
+
+        # we launch the job scheduled every 5 minutes to sync bindings
+        # with the search engine
+        # In our case, the binding is in to_delete, so it should be deleted
+        with trap_jobs() as trap:
+            self.se_index_model.generate_batch_sync_per_index()
+            trap.assert_jobs_count(2)
+            trap.assert_enqueued_job(
+                self.se_index.batch_sync,
+                args=(False,),
+                kwargs={},
+                properties=dict(
+                    identity_key=identity_exact,
+                ),
+            )
+            trap.assert_enqueued_job(
+                self.partner_binding.delete_record,
+                args=(),
+                kwargs={},
+            )
+        self.assertFalse(self.partner_binding.exists())