diff --git a/NEWS.rst b/NEWS.rst index 08c78c8d765da3f8d20f03a27ff48e20a54c6b38_TkVXUy5yc3Q=..0a3fa9ad94afaf928de060d3cb15e5ef96988d91_TkVXUy5yc3Q= 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,6 +1,18 @@ Changelog ========= +18.0.4.0.0 +---------- + +In Model converter, also validate messages in ``message_to_odoo``. + +Model converter argument changed, `__type__` is now the last argument and is optional. It is expected not to be used +anymore. +Added possible_datatypes property and odoo_datatype on Converter. Defaults to empty set and None. +On Model, it can be set. + +Replace generic exception. + 18.0.3.1.0 ---------- diff --git a/__manifest__.py b/__manifest__.py index 08c78c8d765da3f8d20f03a27ff48e20a54c6b38_X19tYW5pZmVzdF9fLnB5..0a3fa9ad94afaf928de060d3cb15e5ef96988d91_X19tYW5pZmVzdF9fLnB5 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020-2024 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 @@ -21,7 +21,7 @@ "name": "Converter", "license": "AGPL-3", "summary": "Convert odoo records to/from plain data structures.", - "version": "18.0.3.1.0", + "version": "18.0.4.0.0", "category": "Hidden", "author": "XCG Consulting", "website": "https://orbeet.io/", diff --git a/base.py b/base.py index 08c78c8d765da3f8d20f03a27ff48e20a54c6b38_YmFzZS5weQ==..0a3fa9ad94afaf928de060d3cb15e5ef96988d91_YmFzZS5weQ== 100644 --- a/base.py +++ b/base.py @@ -43,9 +43,9 @@ from odoo import api, models # type: ignore[import-untyped] from .exception import InternalError -from .validate import Validator +from .validate import Validation, Validator logger = logging.getLogger(__name__) class SkipType: @@ -47,9 +47,20 @@ logger = logging.getLogger(__name__) class SkipType: - pass + def get__type__(self) -> set[str]: + # Avoid conditions isinstance(converter, SkipType) + return set() + + @property + def possible_datatypes(self) -> set[str]: + # Avoid conditions isinstance(converter, SkipType) + return set() + + def odoo_datatype(self, instance: models.BaseModel) -> str | None: + # Avoid conditions isinstance(converter, SkipType) + return None Skip = SkipType() @@ -90,8 +101,13 @@ return ctx +class NotAnInstanceGetterException(Exception): + def __init__(self): + super().__init__("Not an instance getter") + + class Converter: """Base converter class. It does not actually convert anything. """ @@ -93,8 +109,11 @@ class Converter: """Base converter class. It does not actually convert anything. """ + def __init__(self): + self._validation: Validation = Validation.SKIP + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: """From an instance, this method returns a matching value for the message field. @@ -134,7 +153,7 @@ self, odoo_env: api.Environment, message_data ) -> models.BaseModel | NewinstanceType | None: """Return an instance of a model. Check is_instance_getter before calling""" - raise Exception("Not an instance getter") + raise NotAnInstanceGetterException() def get__type__(self) -> set[str]: """Indicate if this converter is associated to several __type__. @@ -166,7 +185,7 @@ ) @property - def validation(self) -> str: + def validation(self) -> Validation: return self._get_validation() @validation.setter @@ -170,6 +189,6 @@ return self._get_validation() @validation.setter - def validation(self, value: str) -> None: + def validation(self, value: Validation) -> None: self._set_validation(value) @@ -174,5 +193,5 @@ self._set_validation(value) - def _get_validation(self) -> str: + def _get_validation(self) -> Validation: return self._validation @@ -177,7 +196,7 @@ return self._validation - def _set_validation(self, value: str) -> None: + def _set_validation(self, value: Validation) -> None: """Define if validation should be done""" assert value is not None self._validation = value @@ -180,7 +199,16 @@ """Define if validation should be done""" assert value is not None self._validation = value + @property + def possible_datatypes(self) -> set[str]: + """Possible values for datatype.""" + # A set, as for get___type__, to allow switch to handle different messages. + return set() + + def odoo_datatype(self, instance: models.BaseModel) -> str | None: + return None + class PostHookConverter(Converter, metaclass=ABCMeta): @abstractmethod @@ -191,6 +219,6 @@ class Readonly(Converter): def __init__(self, conv: Converter): super().__init__() - self.conv = conv + self._conv = conv def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: @@ -195,6 +223,9 @@ def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: - return self.conv.odoo_to_message(instance, ctx) + return self._conv.odoo_to_message(instance, ctx) + + def odoo_datatype(self, instance: models.BaseModel) -> str | None: + return self._conv.odoo_datatype(instance) class Writeonly(Converter): @@ -225,6 +256,10 @@ ) -> models.BaseModel | NewinstanceType | None: return self._conv.get_instance(odoo_env, message_data) + @property + def possible_datatypes(self) -> set[str]: + return self._conv.possible_datatypes + class Computed(Converter): def __init__(self, from_odoo: Callable[[models.BaseModel, Context], Any]): diff --git a/model.py b/model.py index 08c78c8d765da3f8d20f03a27ff48e20a54c6b38_bW9kZWwucHk=..0a3fa9ad94afaf928de060d3cb15e5ef96988d91_bW9kZWwucHk= 100644 --- a/model.py +++ b/model.py @@ -21,7 +21,7 @@ import traceback from collections import OrderedDict from collections.abc import Iterable, Mapping -from typing import Any +from typing import Any, Final import fastjsonschema # type: ignore[import-untyped] from odoo import _, api, models # type: ignore[import-untyped] @@ -42,8 +42,17 @@ _logger = logging.getLogger(__name__) +class IncorrectTypeException(Exception): + """__type__ is in the message is not the same as the expected value""" + + +class MissingRequiredValidatorException(Exception): + def __init__(self): + super().__init__("Strict validation without validator") + + class Model(PostHookConverter): """A converter that takes a dict of key, used when a message has values""" def __init__( self, @@ -45,9 +54,8 @@ class Model(PostHookConverter): """A converter that takes a dict of key, used when a message has values""" def __init__( self, - __type__: str | None, converters: Mapping[str, Converter], json_schema: str | None = None, # The validator is usually not given at this point but is common @@ -56,4 +64,6 @@ merge_with: Iterable[Converter] | None = None, validation: Validation = Validation.SKIP, context: ContextBuilder | None = None, + datatype: str | None = None, + __type__: str | None = None, ): @@ -59,4 +69,7 @@ ): + """ + :param datatype: datatype to use. Usually used with None __type__. + """ super().__init__() self._type: str | None = __type__ self._converters: Mapping[str, Converter] = converters @@ -68,6 +81,7 @@ self.context: ContextBuilder | None = context self.validator = validator self.validation = validation + self._datatype: Final[str | None] = datatype for key, converter in converters.items(): if self._get_instance is None and converter.is_instance_getter: @@ -120,7 +134,7 @@ if self.validation == Validation.STRICT: raise elif self.validation == Validation.STRICT: - raise Exception("Strict validation without validator") + raise MissingRequiredValidatorException() return message_data @@ -134,4 +148,15 @@ ) -> dict | SkipType: values: dict[str, Any] = OrderedDict() + if self.validation != Validation.SKIP and self._jsonschema is not None: + if self.validator: + try: + self.validator.validate(self._jsonschema, message_value) + except (NotInitialized, fastjsonschema.JsonSchemaException): + _logger.warning("Validation failed", exc_info=True) + if self.validation == Validation.STRICT: + raise + elif self.validation == Validation.STRICT: + raise MissingRequiredValidatorException() + if self._type is not None and message_value["__type__"] != self._type: @@ -137,5 +162,5 @@ if self._type is not None and message_value["__type__"] != self._type: - raise Exception( + raise IncorrectTypeException( "Expected __type__ {}, found {}".format( self._type, message_value["__type__"] ) @@ -191,3 +216,13 @@ def get__type__(self) -> set[str]: return set() if self._type is None else {self._type} + + @property + def possible_datatypes(self) -> set[str]: + result = set() + if self._datatype is not None: + result.add(self._datatype) + return result + + def odoo_datatype(self, instance: models.BaseModel) -> str | None: + return self._datatype diff --git a/pyproject.toml b/pyproject.toml index 08c78c8d765da3f8d20f03a27ff48e20a54c6b38_cHlwcm9qZWN0LnRvbWw=..0a3fa9ad94afaf928de060d3cb15e5ef96988d91_cHlwcm9qZWN0LnRvbWw= 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ description = "Odoo addon to convert records to and from plain data structures" dynamic = ["version"] readme = "README.rst" -requires-python = "~=3.11" +requires-python = ">=3.11" license = { file = "LICENSE", name = "GNU Affero General Public License v3" } keywords = ["odoo"] authors = [{ name = "XCG Consulting" }] diff --git a/switch.py b/switch.py index 08c78c8d765da3f8d20f03a27ff48e20a54c6b38_c3dpdGNoLnB5..0a3fa9ad94afaf928de060d3cb15e5ef96988d91_c3dpdGNoLnB5 100644 --- a/switch.py +++ b/switch.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020, 2024 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 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,7 @@ .. code-block:: python - AURION_REFERENTIAL: Switch( + Switch( [ ( lambda record: record.is_xxx, @@ -58,7 +58,7 @@ ] ], validator: Validator | None = None, - validation: str = Validation.SKIP, + validation: Validation = Validation.SKIP, context: ContextBuilder | None = None, ): """ @@ -103,7 +103,7 @@ value_present=value_present, ) - return {} + return Skip @property def is_instance_getter(self) -> bool: @@ -132,7 +132,7 @@ if not isinstance(converter, SkipType): converter.validator = value - def _set_validation(self, value: str) -> None: + def _set_validation(self, value: Validation) -> None: # also set validation on any converters in our switch super()._set_validation(value) for _out_cond, _in_cond, converter in self._converters: @@ -142,6 +142,5 @@ def get__type__(self) -> set[str]: types = set() for _out_cond, _in_cond, converter in self._converters: - if not isinstance(converter, SkipType): - types.update(converter.get__type__()) + types.update(converter.get__type__()) return types @@ -147,1 +146,14 @@ return types + + @property + def possible_datatypes(self) -> set[str]: + result = super().possible_datatypes + for _out_cond, _in_cond, converter in self._converters: + result.update(converter.possible_datatypes) + return result + + def odoo_datatype(self, instance: models.BaseModel) -> str | None: + for out_cond, _in_cond, converter in self._converters: + if out_cond is None or out_cond(instance): + return converter.odoo_datatype(instance) + return super().odoo_datatype(instance) diff --git a/tests/test_base.py b/tests/test_base.py index 08c78c8d765da3f8d20f03a27ff48e20a54c6b38_dGVzdHMvdGVzdF9iYXNlLnB5..0a3fa9ad94afaf928de060d3cb15e5ef96988d91_dGVzdHMvdGVzdF9iYXNlLnB5 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -60,7 +60,6 @@ Field("name"), ) converter = Model( - "res.users", { "user_creator_name": rel, }, @@ -64,6 +63,7 @@ { "user_creator_name": rel, }, + __type__="res.users", ) with self.assertRaises(UserError) as e: converter.odoo_to_message(self.active_user) @@ -84,7 +84,6 @@ def test_convert(self): converter = Model( - None, { "active": Field("active"), "ref": Xref("base"), diff --git a/tests/test_converters.py b/tests/test_converters.py index 08c78c8d765da3f8d20f03a27ff48e20a54c6b38_dGVzdHMvdGVzdF9jb252ZXJ0ZXJzLnB5..0a3fa9ad94afaf928de060d3cb15e5ef96988d91_dGVzdHMvdGVzdF9jb252ZXJ0ZXJzLnB5 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -99,7 +99,6 @@ """ return Model( # TODO add a schema to test validation - "test-type", { "id": Xref(), "country_code": RelationToOne( @@ -108,4 +107,5 @@ "name": Field("name"), "partner_id": RelationToOne("partner_id", "res.partner", Xref()), }, + __type__="test-type", ) diff --git a/tests/test_relation.py b/tests/test_relation.py index 08c78c8d765da3f8d20f03a27ff48e20a54c6b38_dGVzdHMvdGVzdF9yZWxhdGlvbi5weQ==..0a3fa9ad94afaf928de060d3cb15e5ef96988d91_dGVzdHMvdGVzdF9yZWxhdGlvbi5weQ== 100644 --- a/tests/test_relation.py +++ b/tests/test_relation.py @@ -75,9 +75,8 @@ # This converter wraps a user and adds info from its related partner. converter = Model( - None, { "partner": RelationToOne( "partner_id", "res.partner", Model( @@ -79,11 +78,10 @@ { "partner": RelationToOne( "partner_id", "res.partner", Model( - "partner", { "color": Field("color"), "name": Field("name"), "xref": Xref("test"), }, @@ -85,8 +83,9 @@ { "color": Field("color"), "name": Field("name"), "xref": Xref("test"), }, + __type__="partner", ), ), "xref": Xref("base"), @@ -128,9 +127,8 @@ # This converter wraps a user and adds info from its related partner. converter = Model( - None, { "users": RelationToMany( "user_ids", "res.users", Model( @@ -132,10 +130,9 @@ { "users": RelationToMany( "user_ids", "res.users", Model( - "user", { "email": Field("email"), "xref": Xref("base"), }, @@ -138,7 +135,8 @@ { "email": Field("email"), "xref": Xref("base"), }, + __type__="user", ), ), "xref": Xref("base"), @@ -167,3 +165,46 @@ # Check the partner's users self.assertTrue(partner.user_ids) self.assertEqual(len(partner.user_ids), 2) + + def test_many2many_to_odoo_no___type__(self): + """Ensure multiple sub-objects linked from the main one gets updated + when Odoo receives a message. + """ + + # This converter wraps a user and adds info from its related partner. + converter = Model( + { + "users": RelationToMany( + "user_ids", + "res.users", + Model( + { + "email": Field("email"), + "xref": Xref("base"), + }, + ), + ), + "xref": Xref("base"), + }, + ) + + partner = self.env.ref("base.main_partner") + self.assertFalse(partner.user_ids) + + # Run our message reception. + message: dict[str, Any] = { + "users": [ + { + "xref": "user_admin", + }, + { + "xref": "user_demo", + }, + ], + "xref": "main_partner", + } + message_to_odoo(self.env, message, self.env["res.partner"], converter) + + # Check the partner's users + self.assertTrue(partner.user_ids) + self.assertEqual(len(partner.user_ids), 2) diff --git a/tests/test_switch.py b/tests/test_switch.py index 08c78c8d765da3f8d20f03a27ff48e20a54c6b38_dGVzdHMvdGVzdF9zd2l0Y2gucHk=..0a3fa9ad94afaf928de060d3cb15e5ef96988d91_dGVzdHMvdGVzdF9zd2l0Y2gucHk= 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -38,5 +38,4 @@ lambda e: e.active, falser, Model( - "__activebanks__", {"name": Field("name"), "active": Field("active")}, @@ -42,7 +41,8 @@ {"name": Field("name"), "active": Field("active")}, + __type__="__activebanks__", ), ), ( None, falser, Model( @@ -43,8 +43,7 @@ ), ), ( None, falser, Model( - "__inactivebanks__", {"name": Field("name"), "active": Field("active")}, @@ -50,4 +49,5 @@ {"name": Field("name"), "active": Field("active")}, + __type__="__inactivebanks__", ), ), ]