Skip to content
Snippets Groups Projects
Commit 0a3fa9ad94af authored by Vincent Hatakeyama's avatar Vincent Hatakeyama
Browse files

:sparkles: support for datatype in newer sync schemas

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 exceptions.
parent 08c78c8d765d
No related branches found
No related tags found
1 merge request!59✨ support for datatype in newer sync schemas
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
----------
......
##############################################################################
#
# 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/",
......
......@@ -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]):
......
......@@ -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
......@@ -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" }]
......
##############################################################################
#
# 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)
......@@ -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"),
......
......@@ -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",
)
......@@ -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)
......@@ -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__",
),
),
]
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment