diff --git a/NEWS.rst b/NEWS.rst index 3cf99f4ba473da089ca920e8f9e8dfccc98a86fb_TkVXUy5yc3Q=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_TkVXUy5yc3Q= 100644 --- 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 index 3cf99f4ba473da089ca920e8f9e8dfccc98a86fb_bW9kZWxzL2lyX21vZGVsX2RhdGEucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_bW9kZWxzL2lyX21vZGVsX2RhdGEucHk= 100644 --- a/models/ir_model_data.py +++ b/models/ir_model_data.py @@ -38,5 +38,5 @@ _inherit = "ir.model.data" @api.model - def generate_name(self) -> str: + def generate_name(self, prefix: str = "") -> str: """Generate an xref for odoo record; @@ -42,4 +42,5 @@ """Generate an xref for odoo record; + :param prefix: prefix to use before the name. :return: a UUID from a string of 32 hex digit """ @@ -43,7 +44,7 @@ :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( @@ -47,9 +48,12 @@ @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. @@ -51,8 +55,9 @@ ) -> 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,9 +85,12 @@ @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. @@ -82,7 +92,8 @@ ) -> 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( @@ -87,6 +98,6 @@ """ 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 index 3cf99f4ba473da089ca920e8f9e8dfccc98a86fb_c3dpdGNoLnB5..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_c3dpdGNoLnB5 100644 --- a/switch.py +++ b/switch.py @@ -22,7 +22,15 @@ 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 @@ -26,7 +34,7 @@ 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 index 3cf99f4ba473da089ca920e8f9e8dfccc98a86fb_dGVzdHMvdGVzdF9jb252ZXJ0ZXJzLnB5..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF9jb252ZXJ0ZXJzLnB5 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -100,8 +100,8 @@ 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"), @@ -104,8 +104,10 @@ "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 index 3cf99f4ba473da089ca920e8f9e8dfccc98a86fb_dGVzdHMvdGVzdF9yZWxhdGlvbi5weQ==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF9yZWxhdGlvbi5weQ== 100644 --- 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,5 +54,5 @@ 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) @@ -56,5 +58,5 @@ 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) @@ -58,7 +60,7 @@ 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 index 3cf99f4ba473da089ca920e8f9e8dfccc98a86fb_eHJlZi5weQ==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_eHJlZi5weQ== 100644 --- 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,4 +17,8 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## +import logging +import os +import uuid +from typing import Any, Final @@ -20,3 +24,3 @@ -from typing import Any +from odoo import _, api, models # type: ignore[import-untyped] @@ -22,3 +26,11 @@ -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 @@ -24,6 +36,5 @@ -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,5 +44,9 @@ """ 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 = "", ): @@ -37,4 +52,8 @@ ): + """ + :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 @@ -38,7 +57,9 @@ 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 "" @@ -41,7 +62,7 @@ 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 ) @@ -47,6 +68,11 @@ ) + 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: @@ -48,6 +74,7 @@ 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( @@ -53,8 +80,8 @@ 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 @@ -55,7 +82,8 @@ 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( @@ -61,4 +89,4 @@ 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 ) @@ -63,5 +91,16 @@ ) + 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 @@ -65,3 +104,88 @@ @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