# HG changeset patch
# User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr>
# Date 1740067988 -3600
#      Thu Feb 20 17:13:08 2025 +0100
# Branch 17.0
# Node ID 81195e4078e5aa2a32ffc2a6f2c707b998f4eabb
# Parent  3cf99f4ba473da089ca920e8f9e8dfccc98a86fb
🚑 Fix Switch converter to call post_hook, ✨ Xref converter:

- Allow prefix on Xref converter
- Add option to include module name in messages. Incoming and outgoing message value have the same comportment.
  For example, if __converter__ is used as the module, both generated messages and received message will contain __converter__.<name>.
  Previously, generated messages would use the module name while received one would not.

diff --git a/NEWS.rst b/NEWS.rst
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -31,6 +31,15 @@
 
 Replace generic exception.
 
+Fix Switch converter to call post_hook.
+
+Xref converter:
+
+- Allow prefix on Xref converter
+- Add option to include module name in messages. Incoming and outgoing message value have the same comportment.
+  For example, if __converter__ is used as the module, both generated messages and received message will contain __converter__.<name>.
+  Previously, generated messages would use the module name while received one would not.
+
 17.0.1.0.1
 ----------
 
diff --git a/models/ir_model_data.py b/models/ir_model_data.py
--- a/models/ir_model_data.py
+++ b/models/ir_model_data.py
@@ -38,21 +38,26 @@
     _inherit = "ir.model.data"
 
     @api.model
-    def generate_name(self) -> str:
+    def generate_name(self, prefix: str = "") -> str:
         """Generate an xref for odoo record;
+        :param prefix: prefix to use before the name.
         :return: a UUID from a string of 32 hex digit
         """
 
-        return uuid.uuid4().hex
+        return prefix + uuid.uuid4().hex
 
     @api.model
     def object_to_module_and_name(
-        self, record_set: models.BaseModel, module: str | None = _XREF_IMD_MODULE
+        self,
+        record_set: models.BaseModel,
+        module: str | None = _XREF_IMD_MODULE,
+        prefix: str = "",
     ) -> tuple[str, str]:
         """Retrieve an xref pointing to the specified Odoo record; create one
         when missing.
         :param module: Name of the module to use. None to use any name, if no
         xmlid exists "" will be used as the module name.
+        :param prefix: prefix to use before the name.
         :return: tuple module and name
         """
         record_set.ensure_one()
@@ -63,6 +68,8 @@
         ]
         if module is not None:
             domain.append(("module", "=", module))
+        if prefix:
+            domain.append(("name", "=like", f"{prefix}%"))
 
         # Find an existing xref. See class docstring for details.
         imd = self.sudo().search(domain, limit=1)
@@ -70,7 +77,7 @@
             return imd.module, imd.name
 
         # Could not find an existing xref; create one.
-        name = self.generate_name()
+        name = self.generate_name(prefix)
         if module is None:
             module = ""
         self.set_xmlid(record_set, name, module)
@@ -78,15 +85,19 @@
 
     @api.model
     def object_to_xmlid(
-        self, record_set: models.BaseModel, module: str | None = _XREF_IMD_MODULE
+        self,
+        record_set: models.BaseModel,
+        module: str | None = _XREF_IMD_MODULE,
+        prefix: str = "",
     ) -> str:
         """Retrieve an xref pointing to the specified Odoo record; create one
         when missing.
         :param module: Name of the module to use. None to use any name, if no
         xmlid exists "" will be used as the module name.
+        :param prefix: prefix to use before the name.
         """
         return "{0[0]}.{0[1]}".format(
-            self.object_to_module_and_name(record_set, module)
+            self.object_to_module_and_name(record_set, module, prefix)
         )
 
     @api.model
diff --git a/switch.py b/switch.py
--- a/switch.py
+++ b/switch.py
@@ -22,11 +22,19 @@
 
 from odoo import api, models  # type: ignore[import-untyped]
 
-from .base import Context, ContextBuilder, Converter, NewinstanceType, Skip, SkipType
+from .base import (
+    Context,
+    ContextBuilder,
+    Converter,
+    NewinstanceType,
+    PostHookConverter,
+    Skip,
+    SkipType,
+)
 from .validate import Validation, Validator
 
 
-class Switch(Converter):
+class Switch(PostHookConverter):
     """A converter to handle switch cases.
     A list of converters are provided with a function. The first function to
     match is used, any function that is None will be used.
@@ -157,3 +165,10 @@
             if out_cond is None or out_cond(instance):
                 return converter.odoo_datatype(instance)
         return super().odoo_datatype(instance)
+
+    def post_hook(self, instance: models.BaseModel, message_data):
+        for _out_cond, in_cond, converter in self._converters:
+            if in_cond is None or in_cond(message_data):
+                if hasattr(converter, "post_hook"):
+                    converter.post_hook(instance, message_data)
+                return
diff --git a/tests/test_converters.py b/tests/test_converters.py
--- a/tests/test_converters.py
+++ b/tests/test_converters.py
@@ -100,12 +100,14 @@
         return Model(
             # TODO add a schema to test validation
             {
-                "id": Xref(),
+                "id": Xref(include_module_name=True),
                 "country_code": RelationToOne(
                     "country_id", None, KeyField("code", "res.country")
                 ),
                 "name": Field("name"),
-                "partner_id": RelationToOne("partner_id", "res.partner", Xref()),
+                "partner_id": RelationToOne(
+                    "partner_id", "res.partner", Xref(include_module_name=True)
+                ),
             },
             __type__="test-type",
         )
diff --git a/tests/test_relation.py b/tests/test_relation.py
--- a/tests/test_relation.py
+++ b/tests/test_relation.py
@@ -1,7 +1,7 @@
 ##############################################################################
 #
 #    Converter Odoo module
-#    Copyright © 2021, 2024 XCG Consulting <https://xcg-consulting.fr>
+#    Copyright © 2021, 2024, 2025 XCG Consulting <https://xcg-consulting.fr>
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -36,7 +36,9 @@
     @classmethod
     def setUpClass(cls) -> None:
         super().setUpClass()
-        cls.converter1 = RelationToOne("company_id", "res.company", Xref(None))
+        cls.converter1 = RelationToOne(
+            "company_id", "res.company", Xref(None, prefix="main_")
+        )
         cls.converter2 = RelationToOne("action_id", "res.company", Xref(None))
         cls.converter3 = RelationToOne(
             "action_id", "res.company", Xref(None), send_empty=False
@@ -52,13 +54,13 @@
 
     def test_many2one_from_odoo(self):
         message = self.converter1.odoo_to_message(self.user_admin)
-        self.assertEqual(message, "base.main_company")
+        self.assertEqual(message, "company")
         message = self.converter1.odoo_to_message(self.user_root)
-        self.assertEqual(message, "base.main_company")
+        self.assertEqual(message, "company")
 
     def test_many2one_skip_from_odoo(self):
         message = self.converter4.odoo_to_message(self.user_admin)
-        self.assertEqual(message, "base.main_company")
+        self.assertEqual(message, "main_company")
 
     def test_empty_many2one_from_odoo(self):
         message = self.converter2.odoo_to_message(self.user_root)
diff --git a/xref.py b/xref.py
--- a/xref.py
+++ b/xref.py
@@ -1,7 +1,7 @@
 ##############################################################################
 #
 #    Converter Odoo module
-#    Copyright © 2020 XCG Consulting <https://xcg-consulting.fr>
+#    Copyright © 2020, 2025 XCG Consulting <https://xcg-consulting.fr>
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -17,13 +17,24 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ##############################################################################
+import logging
+import os
+import uuid
+from typing import Any, Final
 
-from typing import Any
+from odoo import _, api, models  # type: ignore[import-untyped]
 
-from odoo import api, models  # type: ignore[import-untyped]
+from .base import (
+    Context,
+    ContextBuilder,
+    Converter,
+    NewinstanceType,
+    PostHookConverter,
+    build_context,
+)
+from .models.ir_model_data import _XREF_IMD_MODULE
 
-from .base import Context, NewinstanceType, PostHookConverter
-from .models.ir_model_data import _XREF_IMD_MODULE
+_logger = logging.getLogger(__name__)
 
 
 # TODO dans quel cas ça ne pourrait pas être un instance getter???
@@ -33,35 +44,148 @@
     """
 
     def __init__(
-        self, module: str | None = _XREF_IMD_MODULE, is_instance_getter: bool = True
+        self,
+        module: str | None = _XREF_IMD_MODULE,
+        is_instance_getter: bool = True,
+        include_module_name: bool = False,
+        prefix: str = "",
     ):
+        """
+        :param prefix: prefix to use in ir.model.data, nor sent nor received.
+          Used to prevent duplication if received id is too simple.
+        """
         super().__init__()
         self._module = module
         self._is_instance_getter = is_instance_getter
+        self._include_module_name: Final[bool] = include_module_name
+        self._prefix: Final[str] = prefix
 
     def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         if not instance:
             return ""
-        return instance.env["ir.model.data"].object_to_xmlid(
-            instance, module=self._module
+        module, name = instance.env["ir.model.data"].object_to_module_and_name(
+            instance, self._module, self._prefix
         )
+        if self._prefix is not None:
+            name = name[len(self._prefix) :]
+        if self._include_module_name:
+            return f"{module}.{name}"
+        return name
 
     def get_instance(
         self, odoo_env: api.Environment, message_data
     ) -> models.BaseModel | NewinstanceType | None:
         if self._is_instance_getter:
+            module, name = self._module_name(message_data)
             return odoo_env.ref(
-                ".".join(["" if self._module is None else self._module, message_data]),
+                f"{module}.{name}",
                 raise_if_not_found=False,
             )
         return None
 
     def post_hook(self, instance: models.BaseModel, message_data):
         # add xmlid to the newly created object
+        module, name = self._module_name(message_data)
         instance.env["ir.model.data"].set_xmlid(
-            instance, message_data, module=self._module, only_when_missing=True
+            instance, name, module=module, only_when_missing=True
         )
 
+    def _module_name(self, value: str) -> tuple[str, str]:
+        """Return module and name depending on options"""
+        module = "" if self._module is None else self._module
+        name = value
+        if self._include_module_name:
+            module, name = value.split(".", 1)
+            assert module == self._module
+        if self._prefix is not None:
+            name = self._prefix + name
+        return module, name
+
     @property
     def is_instance_getter(self) -> bool:
         return self._is_instance_getter
+
+
+class JsonLD_ID(Xref):
+    """This converter represents a JsonLD ID , an url made of
+    a base part defined as ir.config_parameter, an optional breadcrumb
+    and a unique id part using the standard xmlid.
+    """
+
+    def __init__(
+        self,
+        breadcrumb: str | Converter,
+        module: str | None = _XREF_IMD_MODULE,
+        is_instance_getter: bool = True,
+        unique_id_field: str | None = None,
+        context: ContextBuilder | None = None,
+        has_base_url: bool = True,
+    ):
+        """
+        :param breadcrumb: Part of the url describing the entity,
+        must match the syntax expected by os.path, ie absolute path
+        begins with a slash. With absolute path the base part is
+        ignored. Can also be a converter, if so, the result of the
+        combined converters must be a string.
+        """
+        super().__init__(
+            module=module,
+            is_instance_getter=is_instance_getter,
+        )
+        self.converter: Converter | None = None
+        self._breadcrumb = breadcrumb if isinstance(breadcrumb, str) else None
+        if isinstance(breadcrumb, Converter):
+            self.converter = breadcrumb
+        self._unique_id_field = unique_id_field
+        self._context = context
+        self._has_base_url = has_base_url
+
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
+        if not instance:
+            return ""
+
+        imds = (
+            instance.env["ir.model.data"]
+            .sudo()
+            .search(
+                [
+                    ("module", "=", self._module),
+                    ("model", "=", instance._name),
+                    ("res_id", "=", instance.id),
+                ]
+            )
+        )
+
+        ctx = build_context(instance, ctx, self._context)
+        jsonld_id_base_url = (
+            instance.env["ir.config_parameter"]
+            .sudo()
+            .get_param("sync.jsonld_id_base_url")
+        )
+        if self._has_base_url and not jsonld_id_base_url:
+            _logger.error(
+                _("Missing config parameter: 'sync.jsonld_id_base_url' is not defined")
+            )
+            return ""
+
+        if self.converter is not None:
+            self._breadcrumb = self.converter.odoo_to_message(instance, ctx)
+
+        # xref does not exist or does not match the jsonld expected format, create it
+        schema_base = os.path.join(
+            jsonld_id_base_url if self._has_base_url else "",
+            self._breadcrumb if self._breadcrumb is not None else "",
+        )
+        if not imds or all(not imd.name.startswith(schema_base) for imd in imds):
+            if self._unique_id_field is not None:
+                name = getattr(instance, self._unique_id_field)
+            else:
+                name = uuid.uuid4().hex
+
+            xref = os.path.join(schema_base, name)
+            instance.env["ir.model.data"].set_xmlid(instance, xref, module=self._module)
+        else:
+            for imd in imds:
+                if imd.name.startswith(schema_base):
+                    xref = imd.name
+        return xref