diff --git a/.badges/maturity.svg b/.badges/maturity.svg index eb1932fd59fdac405a8bafefd21995c78be45b23_LmJhZGdlcy9tYXR1cml0eS5zdmc=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_LmJhZGdlcy9tYXR1cml0eS5zdmc= 100644 --- a/.badges/maturity.svg +++ b/.badges/maturity.svg @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="UTF-8" ?> -<svg xmlns="http://www.w3.org/2000/svg" width="103" height="20"> +<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20"> <linearGradient id="b" x2="0" y2="100%"> <stop offset="0" stop-color="#bbb" stop-opacity=".1" /> <stop offset="1" stop-opacity=".1" /> </linearGradient> <mask id="anybadge_1"> @@ -3,9 +3,9 @@ <linearGradient id="b" x2="0" y2="100%"> <stop offset="0" stop-color="#bbb" stop-opacity=".1" /> <stop offset="1" stop-opacity=".1" /> </linearGradient> <mask id="anybadge_1"> - <rect width="103" height="20" rx="3" fill="#fff" /> + <rect width="108" height="20" rx="3" fill="#fff" /> </mask> <g mask="url(#anybadge_1)"> <path fill="#555" d="M0 0h61v20H0z" /> @@ -9,8 +9,8 @@ </mask> <g mask="url(#anybadge_1)"> <path fill="#555" d="M0 0h61v20H0z" /> - <path fill="#e05d44" d="M61 0h42v20H61z" /> - <path fill="url(#b)" d="M0 0h103v20H0z" /> + <path fill="#4C1" d="M61 0h47v20H61z" /> + <path fill="url(#b)" d="M0 0h108v20H0z" /> </g> <g fill="#fff" @@ -27,7 +27,7 @@ font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11" > - <text x="83.0" y="15" fill="#010101" fill-opacity=".3">Alpha</text> - <text x="82.0" y="14">Alpha</text> + <text x="85.5" y="15" fill="#010101" fill-opacity=".3">Stable</text> + <text x="84.5" y="14">Stable</text> </g> </svg> diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index eb1932fd59fdac405a8bafefd21995c78be45b23_LmdpdGxhYi1jaS55bWw=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_LmdpdGxhYi1jaS55bWw= 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,3 +1,8 @@ include: - project: xcg/ci-templates file: /odoo/17.0/gitlab-ci.yaml + +variables: + TEST_OPTIONS: --test-tags=odoo_addons_mail + # Also install mail to run mail dependant tets + ODOO_SCRIPTS_MODULE_LIST: mail diff --git a/NEWS.rst b/NEWS.rst index eb1932fd59fdac405a8bafefd21995c78be45b23_TkVXUy5yc3Q=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_TkVXUy5yc3Q= 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,6 +1,45 @@ Changelog ========= +17.0.2.0.0 +---------- + +Fixes and changes after making the module typing compliant. + +Evolve: Allow to skip update process. + +Expose Context, NewinstanceType and build_context at the top level package. + +Remove mail dependency, to avoid forcing its installation, only needed when using some specific converters. + +Breaking change: validator package does not assume a odoo.addons package name, provide full package name instead. + +Added Writeonly converter. + +Add some typing information, or make it consistent. + +Add more docstrings. + +Fix using Skip in switch converter. + +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. + +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/README.rst b/README.rst index eb1932fd59fdac405a8bafefd21995c78be45b23_UkVBRE1FLnJzdA==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_UkVBRE1FLnJzdA== 100644 --- a/README.rst +++ b/README.rst @@ -2,6 +2,6 @@ Converter ========= -.. |maturity| image:: .badges/maturity.svg +.. |maturity| image:: https://img.shields.io/badge/maturity-Stable-green.png :target: https://odoo-community.org/page/development-status :alt: Stable @@ -6,5 +6,5 @@ :target: https://odoo-community.org/page/development-status :alt: Stable -.. |license| image:: .badges/licence-AGPL--3-blue.svg +.. |license| image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 @@ -9,5 +9,5 @@ :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 -.. |ruff| image:: .badges/code_style-ruff.svg +.. |ruff| image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json :target: https://github.com/astral-sh/ruff :alt: Ruff @@ -12,6 +12,6 @@ :target: https://github.com/astral-sh/ruff :alt: Ruff -.. |prettier| image:: .badges/code_style-prettier-ff69b4.svg +.. |prettier| image:: https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square :target: https://github.com/prettier/prettier :alt: Prettier diff --git a/__init__.py b/__init__.py index eb1932fd59fdac405a8bafefd21995c78be45b23_X19pbml0X18ucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_X19pbml0X18ucHk= 100644 --- a/__init__.py +++ b/__init__.py @@ -27,6 +27,7 @@ PHASE_UPDATE, Computed, Constant, + Context, ContextBuilder, Converter, Newinstance, @@ -30,5 +31,6 @@ ContextBuilder, Converter, Newinstance, + NewinstanceType, Readonly, Skip, @@ -33,3 +35,5 @@ Readonly, Skip, + SkipType, + Writeonly, message_to_odoo, @@ -35,4 +39,5 @@ message_to_odoo, + build_context, ) from .exception import InternalError from .field import Field, TranslatedSelection @@ -43,9 +48,7 @@ from .relation import RelationToMany, RelationToManyMap, RelationToOne, relation from .switch import Switch from .validate import ( - VALIDATION_SKIP, - VALIDATION_SOFT, - VALIDATION_STRICT, + Validation, NotInitialized, Validator, ) diff --git a/__manifest__.py b/__manifest__.py index eb1932fd59fdac405a8bafefd21995c78be45b23_X19tYW5pZmVzdF9fLnB5..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_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 @@ -17,8 +17,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## - { "name": "Converter", "license": "AGPL-3", "summary": "Convert odoo records to/from plain data structures.", @@ -21,8 +20,8 @@ { "name": "Converter", "license": "AGPL-3", "summary": "Convert odoo records to/from plain data structures.", - "version": "17.0.1.0.1", + "version": "17.0.2.0.0", "category": "Hidden", "author": "XCG Consulting", "website": "https://orbeet.io/", @@ -26,8 +25,7 @@ "category": "Hidden", "author": "XCG Consulting", "website": "https://orbeet.io/", - "depends": ["base", "mail"], - "data": [], + "depends": ["base"], "installable": True, "external_dependencies": {"python": ["fastjsonschema"]}, } diff --git a/base.py b/base.py index eb1932fd59fdac405a8bafefd21995c78be45b23_YmFzZS5weQ==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_YmFzZS5weQ== 100644 --- a/base.py +++ b/base.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 @@ -36,6 +36,7 @@ import inspect import logging +from abc import ABCMeta, abstractmethod from collections.abc import Callable, Mapping from typing import Any @@ -39,6 +40,6 @@ from collections.abc import Callable, Mapping from typing import Any -from odoo import api, models +from odoo import api, models # type: ignore[import-untyped] from .exception import InternalError @@ -43,8 +44,8 @@ from .exception import InternalError -from .validate import Validator +from .validate import Validation, Validator logger = logging.getLogger(__name__) class SkipType: @@ -46,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() @@ -60,7 +72,8 @@ Newinstance = NewinstanceType() -ContextBuilder = Callable[[models.Model, Mapping | None], Mapping | None] +Context = Mapping | None +ContextBuilder = Callable[[models.BaseModel, Context], Context] PHASE_PRECREATE = "precreate" PHASE_POSTCREATE = "postcreate" @@ -71,6 +84,6 @@ def build_context( - instance: models.Model | None, - ctx: Mapping | None, + instance: models.BaseModel | None, + ctx: Context, extend: ContextBuilder | None, @@ -76,5 +89,5 @@ extend: ContextBuilder | None, -) -> dict | None: +) -> Context: if instance is None: return ctx if extend: @@ -82,7 +95,9 @@ ctx = {} else: ctx = dict(ctx) - ctx.update(extend(instance)) + extended = extend(instance, None) + if extended is not None: + ctx.update(extended) return ctx @@ -86,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. """ @@ -89,11 +109,12 @@ class Converter: """Base converter class. It does not actually convert anything. """ - def odoo_to_message( - self, instance: models.Model, ctx: Mapping | None = None - ) -> Any: + 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. :param instance: an instance of an Odoo model @@ -107,5 +128,5 @@ odoo_env: api.Environment, phase: str, message_value: Any, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, @@ -111,5 +132,5 @@ value_present: bool = True, - ) -> dict: + ) -> dict | SkipType: """From a message, returns a dict. Only field whose values are changed are included in the returned dict. :param odoo_env: odoo environment @@ -123,7 +144,7 @@ """ return {} - @classmethod - def is_instance_getter(cls) -> bool: + @property + def is_instance_getter(self) -> bool: return False @@ -128,5 +149,12 @@ return False + # XXX should that be moved to a different class, like PostHookConverter + def get_instance( + self, odoo_env: api.Environment, message_data + ) -> models.BaseModel | NewinstanceType | None: + """Return an instance of a model. Check is_instance_getter before calling""" + raise NotAnInstanceGetterException() + def get__type__(self) -> set[str]: """Indicate if this converter is associated to several __type__. If so, it will be called with incoming messages associated to them. @@ -136,7 +164,7 @@ @property def validator(self) -> Validator | None: """A validator to use for validation of created messages""" - return self._validator + return self._get_validator() @validator.setter def validator(self, value: Validator | None) -> None: @@ -140,6 +168,12 @@ @validator.setter def validator(self, value: Validator | None) -> None: + self._set_validator(value) + + def _get_validator(self) -> Validator | None: + return self._validator + + def _set_validator(self, value: Validator | None) -> None: if value is None: self._validator = None else: @@ -151,6 +185,13 @@ ) @property - def validation(self) -> str: + def validation(self) -> Validation: + return self._get_validation() + + @validation.setter + def validation(self, value: Validation) -> None: + self._set_validation(value) + + def _get_validation(self) -> Validation: return self._validation @@ -155,8 +196,7 @@ return self._validation - @validation.setter - def 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 @@ -159,6 +199,21 @@ """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 + def post_hook(self, instance: models.BaseModel, message_data): + """Post hook""" + class Readonly(Converter): @@ -163,4 +218,18 @@ class Readonly(Converter): - def __init__(self, conv): + def __init__(self, conv: Converter): + super().__init__() + self._conv = conv + + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: + 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): + """A converter that only convert to odoo but does nothing from odoo.""" + + def __init__(self, conv: Converter): super().__init__() @@ -166,3 +235,3 @@ super().__init__() - self.conv = conv + self._conv = conv @@ -168,6 +237,28 @@ - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: - return self.conv.odoo_to_message(instance, ctx) + def message_to_odoo( + self, + odoo_env: api.Environment, + phase: str, + message_value: Any, + instance: models.BaseModel, + value_present: bool = True, + ) -> dict | SkipType: + return self._conv.message_to_odoo( + odoo_env, phase, message_value, instance, value_present + ) + + @property + def is_instance_getter(self) -> bool: + return self._conv.is_instance_getter + + def get_instance( + self, odoo_env: api.Environment, message_data + ) -> 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): @@ -171,7 +262,7 @@ class Computed(Converter): - def __init__(self, from_odoo: Callable[[models.Model, Mapping | None], Any]): + def __init__(self, from_odoo: Callable[[models.BaseModel, Context], Any]): self.from_odoo = from_odoo sig = inspect.signature(from_odoo) @@ -182,7 +273,5 @@ f"{self.from_odoo_arg_count}" ) - def odoo_to_message( - self, instance: models.Model, ctx: Mapping | None = None - ) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: if self.from_odoo_arg_count == 1: @@ -188,6 +277,6 @@ if self.from_odoo_arg_count == 1: - return self.from_odoo(instance) + return self.from_odoo(instance, None) return self.from_odoo(instance, ctx) class Constant(Converter): @@ -190,7 +279,6 @@ return self.from_odoo(instance, ctx) class Constant(Converter): - def __init__(self, value): - self.value = value + """When building messages, this converter return a constant value.""" @@ -196,8 +284,9 @@ - def odoo_to_message( - self, instance: models.Model, ctx: Mapping | None = None - ) -> Any: - return self.value + def __init__(self, value: Any): + self._value = value + + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: + return self._value def message_to_odoo( @@ -206,7 +295,7 @@ model_name: str, converter: Converter, operation: str | None = None, -) -> models.Model: +) -> models.BaseModel: """ :param odoo_env: an Odoo environment @@ -218,6 +307,7 @@ :py:meth:odoo.addons.Converter.get_instance :return: """ + instance: NewinstanceType | models.BaseModel if operation == OPERATION_CREATION: instance = Newinstance else: @@ -228,6 +318,9 @@ changes = converter.message_to_odoo( odoo_env, PHASE_PRECREATE, payload, instance ) + if isinstance(changes, SkipType): + return odoo_env[model_name] + instance = odoo_env[model_name].create(changes) changes = converter.message_to_odoo( odoo_env, PHASE_POSTCREATE, payload, instance @@ -238,6 +331,9 @@ operation is None and not instance or instance is Newinstance ): changes = converter.message_to_odoo(odoo_env, PHASE_UPDATE, payload, instance) + if isinstance(changes, SkipType): + return odoo_env[model_name] + if changes: instance.write(changes) if hasattr(converter, "post_hook"): diff --git a/doc/conf.py b/doc/conf.py index eb1932fd59fdac405a8bafefd21995c78be45b23_ZG9jL2NvbmYucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_ZG9jL2NvbmYucHk= 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -15,7 +15,7 @@ from importlib.metadata import version as import_version import odoo # type: ignore[import-untyped] -from odoo_scripts.config import Configuration +from odoo_scripts.config import Configuration # type: ignore[import-untyped] # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -158,5 +158,5 @@ os.path.dirname(os.path.abspath(odoo.__file__)) ) # sphinxodooautodoc_addons_path : List of paths were Odoo addons to load are located -c = None +c: Configuration | None = None # find setup file of superproject, if any @@ -162,5 +162,10 @@ # find setup file of superproject, if any -directory = os.path.dirname(os.getenv("PWD")) +pwd = os.getenv("PWD") +directory: str | None +if pwd is not None: + directory = os.path.dirname(pwd) +else: + directory = None while not c and directory: setup_path = os.path.join(directory, "setup.cfg") if os.path.isfile(setup_path): @@ -164,9 +169,9 @@ while not c and directory: setup_path = os.path.join(directory, "setup.cfg") if os.path.isfile(setup_path): - c = configparser.ConfigParser() - c.read(setup_path) - if c.has_section("odoo_scripts"): + setup_cfg = configparser.ConfigParser() + setup_cfg.read(setup_path) + if setup_cfg.has_section("odoo_scripts"): # reload with odoo_scripts c = Configuration(directory) else: @@ -182,7 +187,8 @@ if c: addon_dirs = set(os.path.dirname(path) for path in c.modules) - for line in addon_dirs: - sphinxodooautodoc_addons_path.append(os.path.join(directory, line)) + if directory is not None: + for line in addon_dirs: + sphinxodooautodoc_addons_path.append(os.path.join(directory, line)) else: # add this directory top dir @@ -187,8 +193,7 @@ else: # add this directory top dir - sphinxodooautodoc_addons_path.append( - os.path.dirname(os.path.dirname(os.getenv("PWD"))) - ) + if pwd is not None: + sphinxodooautodoc_addons_path.append(os.path.dirname(os.path.dirname(pwd))) other_addons = os.getenv("ODOO_ADDONS_PATH", default=None) if other_addons: for addon_path in other_addons.split(","): diff --git a/field.py b/field.py index eb1932fd59fdac405a8bafefd21995c78be45b23_ZmllbGQucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_ZmllbGQucHk= 100644 --- a/field.py +++ b/field.py @@ -20,6 +20,6 @@ import datetime from collections.abc import Callable -from typing import Any +from typing import Any, Literal import pytz @@ -24,4 +24,4 @@ import pytz -from odoo import api, models +from odoo import api, models # type: ignore[import-untyped] @@ -27,5 +27,5 @@ -from .base import PHASE_POSTCREATE, Converter, Newinstance, Skip +from .base import PHASE_POSTCREATE, Context, Converter, Newinstance, Skip, SkipType class Field(Converter): @@ -61,7 +61,7 @@ self.default = default self.send_empty = send_empty or required_blank_value is not None self.required_blank_value = required_blank_value - self._blank_value = None + self._blank_value: Literal[False, "", 0] | list | float | None = None self._message_formatter = message_formatter self._odoo_formatter = odoo_formatter @@ -87,7 +87,7 @@ return self._blank_value - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: value = False # could be empty due to chaining converter on a many2one without value # for example @@ -120,5 +120,5 @@ odoo_env: api.Environment, phase: str, message_value: Any, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, @@ -124,5 +124,5 @@ value_present: bool = True, - ) -> dict: + ) -> dict | SkipType: if phase == PHASE_POSTCREATE: return {} if not value_present: @@ -133,7 +133,7 @@ # do not include value if already the same if instance and instance is not Newinstance: value = self.odoo_to_message(instance) - if value is Skip or value == message_value: + if isinstance(value, SkipType) or value == message_value: return {} if self._odoo_formatter: message_value = self._odoo_formatter(message_value) @@ -161,7 +161,7 @@ super().__init__(field_name, default, send_empty, required_blank_value) self._language = language - def _lazy_dicts(self, instance: models.Model): + def _lazy_dicts(self, instance: models.BaseModel): if not hasattr(self, "_lazy_dict_odoo_to_message"): description_selection = ( instance.with_context(lang=self._language) @@ -173,7 +173,7 @@ (b, a) for a, b in description_selection ) - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: value = super().odoo_to_message(instance, ctx) if value: self._lazy_dicts(instance) @@ -185,5 +185,5 @@ odoo_env: api.Environment, phase: str, message_value: Any, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, @@ -189,5 +189,5 @@ value_present: bool = True, - ) -> dict: + ) -> dict | SkipType: message = super().message_to_odoo( odoo_env, phase, message_value, instance, value_present ) @@ -191,7 +191,7 @@ message = super().message_to_odoo( odoo_env, phase, message_value, instance, value_present ) - if self.field_name in message: + if not isinstance(message, SkipType) and self.field_name in message: self._lazy_dicts(instance) message[self.field_name] = self._lazy_dict_message_to_odoo.get( message[self.field_name] diff --git a/keyfield.py b/keyfield.py index eb1932fd59fdac405a8bafefd21995c78be45b23_a2V5ZmllbGQucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_a2V5ZmllbGQucHk= 100644 --- a/keyfield.py +++ b/keyfield.py @@ -20,5 +20,5 @@ from typing import Any -from odoo import models +from odoo import api, models # type: ignore[import-untyped] @@ -24,5 +24,5 @@ -from .base import Converter +from .base import Context, Converter, NewinstanceType class KeyField(Converter): @@ -39,6 +39,6 @@ self.model_name = model_name self.lookup_limit = 1 if limit_lookup else None - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: return getattr(instance, self.field_name) @@ -43,6 +43,8 @@ return getattr(instance, self.field_name) - def get_instance(self, odoo_env, message_data): + def get_instance( + self, odoo_env: api.Environment, message_data + ) -> models.BaseModel | NewinstanceType | None: instance = odoo_env[self.model_name].search( [(self.field_name, "=", message_data)], limit=self.lookup_limit ) @@ -50,6 +52,6 @@ instance.ensure_one() return instance - @classmethod - def is_instance_getter(cls) -> bool: + @property + def is_instance_getter(self) -> bool: return True diff --git a/list.py b/list.py index eb1932fd59fdac405a8bafefd21995c78be45b23_bGlzdC5weQ==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_bGlzdC5weQ== 100644 --- a/list.py +++ b/list.py @@ -20,5 +20,5 @@ from typing import Any -from odoo import models +from odoo import api, models # type: ignore[import-untyped] @@ -24,5 +24,5 @@ -from .base import ContextBuilder, Converter, Skip, build_context +from .base import Context, ContextBuilder, Converter, SkipType, build_context class List(Converter): @@ -37,10 +37,10 @@ self._converters = converters self.context = context - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: ctx = build_context(instance, ctx, self.context) message_data = [] for converter in self._converters: value = converter.odoo_to_message(instance, ctx) @@ -41,13 +41,13 @@ ctx = build_context(instance, ctx, self.context) message_data = [] for converter in self._converters: value = converter.odoo_to_message(instance, ctx) - if value is not Skip: + if not isinstance(value, SkipType): message_data.append(value) return message_data def message_to_odoo( self, @@ -48,8 +48,8 @@ message_data.append(value) return message_data def message_to_odoo( self, - odoo_env, + odoo_env: api.Environment, phase: str, @@ -55,4 +55,4 @@ phase: str, - message_value, - instance: models.Model, + message_value: Any, + instance: models.BaseModel, value_present: bool = True, @@ -58,3 +58,10 @@ value_present: bool = True, - ) -> dict: - return {} + ) -> dict | SkipType: + result = {} + for i, converter in enumerate(self._converters): + new_values = converter.message_to_odoo( + odoo_env, phase, message_value[i], instance, value_present + ) + if not isinstance(new_values, SkipType): + result.update(new_values) + return result diff --git a/mail_template.py b/mail_template.py index eb1932fd59fdac405a8bafefd21995c78be45b23_bWFpbF90ZW1wbGF0ZS5weQ==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_bWFpbF90ZW1wbGF0ZS5weQ== 100644 --- a/mail_template.py +++ b/mail_template.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 2024 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,7 +17,6 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## - import ast from typing import Any @@ -21,5 +20,5 @@ import ast from typing import Any -from odoo import models +from odoo import models # type: ignore[import-untyped] @@ -25,4 +24,4 @@ -from . import base +from .base import Context, Converter @@ -27,5 +26,5 @@ -class MailTemplate(base.Converter): +class MailTemplate(Converter): """This converter wraps ``mail.template::_render_template``. Multiple records are allowed but ``mail.template::_render_template`` still @@ -30,9 +29,11 @@ """This converter wraps ``mail.template::_render_template``. Multiple records are allowed but ``mail.template::_render_template`` still - runs once per record; to accomodate, we provide ``ctx["records"]``. + runs once per record; to accommodate, we provide ``ctx["records"]``. + + Using this converter requires the mail module to be installed. """ def __init__(self, template: str, post_eval: bool = False): self.template = template self.post_eval = post_eval @@ -33,10 +34,10 @@ """ def __init__(self, template: str, post_eval: bool = False): self.template = template self.post_eval = post_eval - def odoo_to_message(self, records: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, records: models.BaseModel, ctx: Context = None) -> Any: value = ( records.env["mail.template"] .with_context(records=records, safe=True) diff --git a/model.py b/model.py index eb1932fd59fdac405a8bafefd21995c78be45b23_bW9kZWwucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_bW9kZWwucHk= 100644 --- a/model.py +++ b/model.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 2024 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,8 +17,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## - import logging import traceback from collections import OrderedDict from collections.abc import Iterable, Mapping @@ -21,6 +20,6 @@ import logging import traceback from collections import OrderedDict from collections.abc import Iterable, Mapping -from typing import Any +from typing import Any, Final @@ -26,5 +25,6 @@ -from odoo import _, api, models -from odoo.exceptions import UserError +import fastjsonschema # type: ignore[import-untyped] +from odoo import _, api, models # type: ignore[import-untyped] +from odoo.exceptions import UserError # type: ignore[import-untyped] from .base import ( @@ -29,6 +29,7 @@ from .base import ( + Context, ContextBuilder, Converter, Newinstance, NewinstanceType, @@ -31,7 +32,8 @@ ContextBuilder, Converter, Newinstance, NewinstanceType, - Skip, + PostHookConverter, + SkipType, build_context, ) @@ -36,7 +38,7 @@ build_context, ) -from .validate import VALIDATION_SKIP, VALIDATION_STRICT, Validator +from .validate import NotInitialized, Validation, Validator _logger = logging.getLogger(__name__) @@ -39,9 +41,18 @@ _logger = logging.getLogger(__name__) -class Model(Converter): +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, @@ -44,11 +55,10 @@ """A converter that takes a dict of key, used when a message has values""" def __init__( self, - __type__: str, converters: Mapping[str, Converter], json_schema: str | None = None, # The validator is usually not given at this point but is common # throughout a project. That’s why it is a property validator: Validator | None = None, merge_with: Iterable[Converter] | None = None, @@ -49,8 +59,8 @@ converters: Mapping[str, Converter], json_schema: str | None = None, # The validator is usually not given at this point but is common # throughout a project. That’s why it is a property validator: Validator | None = None, merge_with: Iterable[Converter] | None = None, - validation: str = VALIDATION_SKIP, + validation: Validation = Validation.SKIP, context: ContextBuilder | None = None, @@ -56,2 +66,4 @@ context: ContextBuilder | None = None, + datatype: str | None = None, + __type__: str | None = None, ): @@ -57,2 +69,5 @@ ): + """ + :param datatype: datatype to use. Usually used with None __type__. + """ super().__init__() @@ -58,3 +73,3 @@ super().__init__() - self._type: str = __type__ + self._type: str | None = __type__ self._converters: Mapping[str, Converter] = converters @@ -60,3 +75,3 @@ self._converters: Mapping[str, Converter] = converters - self._post_hooks_converters_key: list[str] = [] + self._post_hooks_converters: dict[str, PostHookConverter] = {} self._jsonschema: str | None = json_schema @@ -62,5 +77,5 @@ self._jsonschema: str | None = json_schema - self._get_instance: Converter = None + self._get_instance: str | None = None """First converter whose `is_instance_getter` is true if any""" self.merge_with: Iterable[Converter] | None = merge_with self.context: ContextBuilder | None = context @@ -64,5 +79,5 @@ """First converter whose `is_instance_getter` is true if any""" self.merge_with: Iterable[Converter] | None = merge_with self.context: ContextBuilder | None = context - self.validator: Validator | None = validator + self.validator = validator self.validation = validation @@ -68,3 +83,4 @@ self.validation = validation + self._datatype: Final[str | None] = datatype for key, converter in converters.items(): @@ -69,4 +85,4 @@ for key, converter in converters.items(): - if self._get_instance is None and converter.is_instance_getter(): + if self._get_instance is None and converter.is_instance_getter: self._get_instance = key @@ -72,4 +88,4 @@ self._get_instance = key - if hasattr(converter, "post_hook"): - self._post_hooks_converters_key.append(key) + if isinstance(converter, PostHookConverter): + self._post_hooks_converters[key] = converter @@ -75,5 +91,5 @@ - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: ctx = build_context(instance, ctx, self.context) message_data = {} @@ -77,7 +93,7 @@ ctx = build_context(instance, ctx, self.context) message_data = {} - if self._type: + if self._type is not None: message_data["__type__"] = self._type errors = [] @@ -89,7 +105,7 @@ {"key": key, "traceback": "".join(traceback.format_exception(e))} ) continue - if value is not Skip: + if not isinstance(value, SkipType): message_data[key] = value if len(errors) != 0: formatted_errors = "\n\n".join( @@ -105,7 +121,7 @@ if self.merge_with: for conv in self.merge_with: value = conv.odoo_to_message(instance, ctx) - if value is Skip: + if isinstance(value, SkipType): continue message_data.update(value) @@ -109,13 +125,16 @@ continue message_data.update(value) - if self.validation != VALIDATION_SKIP and self._jsonschema is not None: - try: - self.validator.validate(self._jsonschema, message_data) - except Exception as exception: - _logger.warning("Validation failed", exc_info=1) - if self.validation == VALIDATION_STRICT: - raise exception + if self.validation != Validation.SKIP and self._jsonschema is not None: + if self.validator: + try: + self.validator.validate(self._jsonschema, message_data) + except (NotInitialized, fastjsonschema.JsonSchemaException): + _logger.warning("Validation failed", exc_info=True) + if self.validation == Validation.STRICT: + raise + elif self.validation == Validation.STRICT: + raise MissingRequiredValidatorException() return message_data @@ -124,5 +143,5 @@ odoo_env: api.Environment, phase: str, message_value: Any, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, @@ -128,4 +147,4 @@ value_present: bool = True, - ) -> dict: - values = OrderedDict() + ) -> dict | SkipType: + values: dict[str, Any] = OrderedDict() @@ -131,9 +150,20 @@ - if self._type and message_value["__type__"] != self._type: - raise Exception( + 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: + raise IncorrectTypeException( "Expected __type__ {}, found {}".format( self._type, message_value["__type__"] ) ) for key in self._converters: value = message_value.get(key, None) if message_value else None @@ -134,15 +164,13 @@ "Expected __type__ {}, found {}".format( self._type, message_value["__type__"] ) ) for key in self._converters: value = message_value.get(key, None) if message_value else None - values.update( - self._converters[key].message_to_odoo( - odoo_env, - phase, - value, - instance, - message_value and key in message_value, - ) + attribute_vals = self._converters[key].message_to_odoo( + odoo_env, + phase, + value, + instance, + message_value and key in message_value, ) @@ -148,6 +176,9 @@ ) + if isinstance(attribute_vals, SkipType): + continue + values.update(attribute_vals) if self.merge_with: for conv in self.merge_with: value = conv.message_to_odoo( odoo_env, phase, message_value, instance, value_present ) @@ -149,11 +180,11 @@ if self.merge_with: for conv in self.merge_with: value = conv.message_to_odoo( odoo_env, phase, message_value, instance, value_present ) - if value is Skip: + if isinstance(value, SkipType): continue values.update(value) return values @@ -155,10 +186,11 @@ continue values.update(value) return values + @property def is_instance_getter(self) -> bool: return self._get_instance is not None def get_instance( self, odoo_env: api.Environment, message_data @@ -160,9 +192,9 @@ def is_instance_getter(self) -> bool: return self._get_instance is not None def get_instance( self, odoo_env: api.Environment, message_data - ) -> None | models.Model | NewinstanceType: + ) -> models.BaseModel | NewinstanceType | None: """:return: an instance linked to the converter, if any""" if self._get_instance: instance = self._converters[self._get_instance].get_instance( @@ -173,6 +205,6 @@ return instance return None - def post_hook(self, instance: models.Model, message_data): - for key in self._post_hooks_converters_key: + def post_hook(self, instance: models.BaseModel, message_data): + for key in self._post_hooks_converters: if key in message_data: @@ -178,8 +210,8 @@ if key in message_data: - self._converters[key].post_hook(instance, message_data[key]) + self._post_hooks_converters[key].post_hook(instance, message_data[key]) if self.merge_with: for converter in self.merge_with: if hasattr(converter, "post_hook"): converter.post_hook(instance, message_data) def get__type__(self) -> set[str]: @@ -180,7 +212,17 @@ if self.merge_with: for converter in self.merge_with: if hasattr(converter, "post_hook"): converter.post_hook(instance, message_data) def get__type__(self) -> set[str]: - return {self._type} + 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/models/ir_model_data.py b/models/ir_model_data.py index eb1932fd59fdac405a8bafefd21995c78be45b23_bW9kZWxzL2lyX21vZGVsX2RhdGEucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_bW9kZWxzL2lyX21vZGVsX2RhdGEucHk= 100644 --- a/models/ir_model_data.py +++ b/models/ir_model_data.py @@ -19,4 +19,5 @@ ############################################################################## import uuid +from typing import Final @@ -22,4 +23,4 @@ -from odoo import api, models +from odoo import api, models # type: ignore[import-untyped] # Xrefs are stored within "ir.model.data" with this module name. @@ -24,5 +25,5 @@ # Xrefs are stored within "ir.model.data" with this module name. -_XREF_IMD_MODULE = "__converter__" +_XREF_IMD_MODULE: Final[str] = "__converter__" @@ -27,6 +28,6 @@ -class IrModelData(models.Model): +class IrModelData(models.BaseModel): """Add xref tools. All done with the super-admin user to bypass security rules. @@ -37,5 +38,5 @@ _inherit = "ir.model.data" @api.model - def generate_name(self): + def generate_name(self, prefix: str = "") -> str: """Generate an xref for odoo record; @@ -41,4 +42,5 @@ """Generate an xref for odoo record; - It return a UUID from a string of 32 hex digit + :param prefix: prefix to use before the name. + :return: a UUID from a string of 32 hex digit """ @@ -43,5 +45,5 @@ """ - return uuid.uuid4().hex + return prefix + uuid.uuid4().hex @api.model @@ -46,7 +48,12 @@ @api.model - def object_to_module_and_name(self, record_set, module=_XREF_IMD_MODULE): + def object_to_module_and_name( + 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. @@ -49,9 +56,8 @@ """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. - :type module: Optional[str] - :rtype: Tuple[str, str] + :param prefix: prefix to use before the name. :return: tuple module and name """ record_set.ensure_one() @@ -62,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) @@ -69,10 +77,10 @@ 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) return module, name @api.model @@ -73,11 +81,16 @@ if module is None: module = "" self.set_xmlid(record_set, name, module) return module, name @api.model - def object_to_xmlid(self, record_set, module=_XREF_IMD_MODULE): + def object_to_xmlid( + 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. @@ -80,8 +93,7 @@ """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. - :type module: Optional[str] - :rtype: xmlid + :param prefix: prefix to use before the name. """ return "{0[0]}.{0[1]}".format( @@ -86,8 +98,8 @@ """ 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 def set_xmlid( self, @@ -89,8 +101,8 @@ ) @api.model def set_xmlid( self, - record_set: models.Model, + record_set: models.BaseModel, name: str, module: str = _XREF_IMD_MODULE, @@ -95,6 +107,6 @@ name: str, module: str = _XREF_IMD_MODULE, - only_when_missing: str = False, + only_when_missing: bool = False, ): """Save an external reference to the specified Odoo record. :param module: Name of the module to use. diff --git a/py.typed b/py.typed new file mode 100644 diff --git a/pyproject.toml b/pyproject.toml index eb1932fd59fdac405a8bafefd21995c78be45b23_cHlwcm9qZWN0LnRvbWw=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_cHlwcm9qZWN0LnRvbWw= 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [project] name = "odoo-addon-converter" +description = "Odoo addon to convert records to and from plain data structures" dynamic = ["version"] readme = "README.rst" requires-python = "~=3.10" @@ -42,6 +43,7 @@ [tool.hatch.build.targets.wheel] include = [ + "py.typed", "*.csv", "/i18n/", "/static/", @@ -49,7 +51,8 @@ "*.xml", "*.py", "*.svg", - "*.png" + "*.png", + "*.json" ] [tool.hatch.build.targets.wheel.sources] @@ -57,24 +60,7 @@ [tool.hatch.version] source = "vcs" - -[tool.isort] -section-order = [ - "future", - "standard-library", - "third-party", - "odoo", - "odoo-addons", - "first-party", - "local-folder" -] - -[tool.isort.sections] -"odoo" = ["odoo"] -"odoo-addons" = ["odoo.addons"] - -[tool.ruff.lint.mccabe] -max-complexity = 16 +fallback-version = "17.0.0.0.0" [tool.ruff] target-version = "py310" @@ -92,3 +78,21 @@ [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401", "I001"] # ignore unused and unsorted imports in __init__.py "__manifest__.py" = ["B018"] # useless expression + +[tool.ruff.lint.mccabe] +max-complexity = 16 + +[tool.isort] +section-order = [ + "future", + "standard-library", + "third-party", + "odoo", + "odoo-addons", + "first-party", + "local-folder" +] + +[tool.isort.sections] +"odoo" = ["odoo"] +"odoo-addons" = ["odoo.addons"] diff --git a/relation.py b/relation.py index eb1932fd59fdac405a8bafefd21995c78be45b23_cmVsYXRpb24ucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_cmVsYXRpb24ucHk= 100644 --- a/relation.py +++ b/relation.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 2024 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,8 +17,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## - import logging from collections.abc import Callable from typing import Any @@ -21,6 +20,6 @@ import logging from collections.abc import Callable from typing import Any -from odoo import api, models +from odoo import api, models # type: ignore[import-untyped] @@ -26,5 +25,13 @@ -from .base import ContextBuilder, Converter, NewinstanceType, Skip, build_context +from .base import ( + Context, + ContextBuilder, + Converter, + NewinstanceType, + Skip, + SkipType, + build_context, +) from .field import Field _logger = logging.getLogger(__name__) @@ -34,7 +41,7 @@ def __init__( self, field_name: str, - model_name: str, + model_name: str | None, converter: Converter, send_empty: bool = True, context: ContextBuilder | None = None, @@ -45,7 +52,7 @@ self._send_empty = send_empty self.context = context - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: ctx = build_context(instance, ctx, self.context) # do not use super, otherwise if empty, will convert that relation_instance = getattr(instance, self.field_name) @@ -61,5 +68,5 @@ odoo_env: api.Environment, phase: str, message_value: Any, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, @@ -65,5 +72,5 @@ value_present: bool = True, - ) -> dict: + ) -> dict | SkipType: if not value_present: return {} @@ -82,8 +89,8 @@ field_name: str, model_name: str | None, converter: Converter, - sortkey: None | Callable[[models.Model], bool] = None, - filtered: None | str | Callable[[models.Model], bool] = None, + sortkey: None | Callable[[models.BaseModel], bool] = None, + filtered: None | str | Callable[[models.BaseModel], bool] = None, context: ContextBuilder | None = None, limit: Any | None = None, ): @@ -98,6 +105,6 @@ self.context = context self.limit = limit - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: ctx = build_context(instance, ctx, self.context) value = super().odoo_to_message(instance, ctx) @@ -102,7 +109,7 @@ ctx = build_context(instance, ctx, self.context) value = super().odoo_to_message(instance, ctx) - if value is Skip: - return Skip + if isinstance(value, SkipType): + return value if self.filtered: value = value.filtered(self.filtered) if self.sortkey: @@ -113,7 +120,7 @@ return [ m for m in (self.converter.odoo_to_message(r, ctx) for r in value) - if m is not Skip + if not isinstance(m, SkipType) ] def message_to_odoo( @@ -121,5 +128,5 @@ odoo_env: api.Environment, phase: str, message_value: Any, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, @@ -125,5 +132,5 @@ value_present: bool = True, - ) -> dict: + ) -> dict | SkipType: # if not present or value is None, do not update the values. if not value_present or message_value is None: return {} @@ -151,7 +158,7 @@ model_name: str | None, key_converter: Converter, value_converter: Converter, - filtered: None | str | Callable[[models.Model], bool] = None, + filtered: None | str | Callable[[models.BaseModel], bool] = None, context: ContextBuilder | None = None, ): """ @@ -164,6 +171,6 @@ self.filtered = filtered self.context = context - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: ctx = build_context(instance, ctx, self.context) value = super().odoo_to_message(instance, ctx) @@ -168,7 +175,7 @@ ctx = build_context(instance, ctx, self.context) value = super().odoo_to_message(instance, ctx) - if value is Skip: - return Skip + if isinstance(value, SkipType): + return value if self.filtered: value = value.filtered(self.filtered) return { @@ -180,7 +187,7 @@ ) for r in value ) - if k is not Skip and v is not Skip + if not isinstance(k, SkipType) and not isinstance(v, SkipType) } def message_to_odoo( @@ -188,5 +195,5 @@ odoo_env: api.Environment, phase: str, message_value: Any, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, @@ -192,5 +199,5 @@ value_present: bool = True, - ) -> dict: + ) -> dict | SkipType: # if not present or value is None, do not update the values. if not value_present or message_value is None: return {} diff --git a/switch.py b/switch.py index eb1932fd59fdac405a8bafefd21995c78be45b23_c3dpdGNoLnB5..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_c3dpdGNoLnB5 100644 --- a/switch.py +++ b/switch.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020 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 @@ -17,7 +17,6 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## - -from collections.abc import Callable, Mapping +from collections.abc import Callable from typing import Any @@ -22,4 +21,4 @@ from typing import Any -from odoo import api, models +from odoo import api, models # type: ignore[import-untyped] @@ -25,5 +24,13 @@ -from .base import ContextBuilder, Converter, Skip, SkipType -from .validate import VALIDATION_SKIP, Validator +from .base import ( + Context, + ContextBuilder, + Converter, + NewinstanceType, + PostHookConverter, + Skip, + SkipType, +) +from .validate import Validation, Validator @@ -28,6 +35,6 @@ -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. @@ -37,6 +44,6 @@ .. code-block:: python - AURION_REFERENTIAL: Switch( + Switch( [ ( @@ -41,7 +48,7 @@ [ ( - lambda e: e.is_xxx, - lambda p: "wave_code" in p, + lambda record: record.is_xxx, + lambda message_value: "wave_code" in message_value, Model("__wave__", {}), ), (None, None, Model("__event__", {})), @@ -53,9 +60,9 @@ self, converters: list[ tuple[ - Callable[[models.Model], bool], - Callable[[Any], bool], - Converter, + Callable[[models.BaseModel], bool] | None, + Callable[[Any], bool] | None, + Converter | SkipType, ] ], validator: Validator | None = None, @@ -59,7 +66,7 @@ ] ], validator: Validator | None = None, - validation: str = VALIDATION_SKIP, + validation: Validation = Validation.SKIP, context: ContextBuilder | None = None, ): """ @@ -74,9 +81,7 @@ self.validator = validator self.validation = validation - def odoo_to_message( - self, instance: models.Model, ctx: Mapping | None = None - ) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: for out_cond, _in_cond, converter in self._converters: if out_cond is None or out_cond(instance): if isinstance(converter, SkipType): @@ -91,5 +96,5 @@ odoo_env: api.Environment, phase: str, message_value: Any, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, @@ -95,3 +100,3 @@ value_present: bool = True, - ) -> dict: + ) -> dict | SkipType: for _out_cond, in_cond, converter in self._converters: @@ -97,5 +102,7 @@ for _out_cond, in_cond, converter in self._converters: - if in_cond is None or in_cond(message_value): + if not isinstance(converter, SkipType) and ( + in_cond is None or in_cond(message_value) + ): return converter.message_to_odoo( odoo_env, phase, @@ -106,5 +113,6 @@ return Skip + @property def is_instance_getter(self) -> bool: for _out_cond, _in_cond, converter in self._converters: @@ -109,7 +117,7 @@ def is_instance_getter(self) -> bool: for _out_cond, _in_cond, converter in self._converters: - if converter.is_instance_getter(): + if not isinstance(converter, SkipType) and converter.is_instance_getter: return True return False @@ -112,6 +120,8 @@ return True return False - def get_instance(self, odoo_env, message_data): + def get_instance( + self, odoo_env: api.Environment, message_data + ) -> models.BaseModel | NewinstanceType | None: for _out_cond, in_cond, converter in self._converters: @@ -117,5 +127,7 @@ for _out_cond, in_cond, converter in self._converters: - if converter.is_instance_getter() and ( - in_cond is None or in_cond(message_data) + if ( + not isinstance(converter, SkipType) + and converter.is_instance_getter + and (in_cond is None or in_cond(message_data)) ): return converter.get_instance(odoo_env, message_data) @@ -120,3 +132,4 @@ ): return converter.get_instance(odoo_env, message_data) + return super().get_instance(odoo_env, message_data) @@ -122,4 +135,3 @@ - @Converter.validator.setter - def validator(self, value: Validator | None) -> None: + def _set_validator(self, value: Validator | None) -> None: # also set validator on any converters in our switch, in case they care @@ -125,3 +137,3 @@ # also set validator on any converters in our switch, in case they care - Converter.validator.fset(self, value) + super()._set_validator(value) for _out_cond, _in_cond, converter in self._converters: @@ -127,3 +139,4 @@ for _out_cond, _in_cond, converter in self._converters: - converter.validator = value + if not isinstance(converter, SkipType): + converter.validator = value @@ -129,4 +142,3 @@ - @Converter.validation.setter - def validation(self, value: str) -> None: + def _set_validation(self, value: Validation) -> None: # also set validation on any converters in our switch @@ -132,3 +144,3 @@ # also set validation on any converters in our switch - Converter.validation.fset(self, value) + super()._set_validation(value) for _out_cond, _in_cond, converter in self._converters: @@ -134,8 +146,9 @@ for _out_cond, _in_cond, converter in self._converters: - converter.validation = value + if not isinstance(converter, SkipType): + converter.validation = value def get__type__(self) -> set[str]: types = set() for _out_cond, _in_cond, converter in self._converters: types.update(converter.get__type__()) return types @@ -136,6 +149,26 @@ def get__type__(self) -> set[str]: types = set() for _out_cond, _in_cond, converter in self._converters: types.update(converter.get__type__()) 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) + + 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/__init__.py b/tests/__init__.py index eb1932fd59fdac405a8bafefd21995c78be45b23_dGVzdHMvX19pbml0X18ucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvX19pbml0X18ucHk= 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -6,4 +6,5 @@ test_mail_template, test_relation, test_switch, + test_validate, ) diff --git a/tests/schemas/__init__.py b/tests/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvc2NoZW1hcy9fX2luaXRfXy5weQ== --- /dev/null +++ b/tests/schemas/__init__.py @@ -0,0 +1,11 @@ +import json +import pkgutil +from typing import Any +from collections.abc import Generator + + +def get_schemas() -> Generator[Any]: + for file_prefix in ("product",): + data: bytes | None = pkgutil.get_data(__name__, f"{file_prefix}.schema.json") + if data: + yield json.loads(data) diff --git a/tests/schemas/product.schema.json b/tests/schemas/product.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvc2NoZW1hcy9wcm9kdWN0LnNjaGVtYS5qc29u --- /dev/null +++ b/tests/schemas/product.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + + "$id": "https://example.com/product.schema.json", + + "title": "Product", + + "description": "A product from Acme's catalog", + + "type": "object", + + "properties": { + "productId": { + "description": "The unique identifier for a product", + + "type": "integer" + }, + + "productName": { + "description": "Name of the product", + + "type": "string" + }, + + "price": { + "description": "The price of the product", + + "type": "number", + + "exclusiveMinimum": 0 + }, + + "tags": { + "description": "Tags for the product", + + "type": "array", + + "items": { + "type": "string" + }, + + "minItems": 1, + + "uniqueItems": true + } + }, + + "required": ["productId", "productName", "price"] +} diff --git a/tests/schemas_dir/product.schema.json b/tests/schemas_dir/product.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvc2NoZW1hc19kaXIvcHJvZHVjdC5zY2hlbWEuanNvbg== --- /dev/null +++ b/tests/schemas_dir/product.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + + "$id": "https://example.com/product.schema.json", + + "title": "Product", + + "description": "A product from Acme's catalog", + + "type": "object", + + "properties": { + "productId": { + "description": "The unique identifier for a product", + + "type": "integer" + }, + + "productName": { + "description": "Name of the product", + + "type": "string" + }, + + "price": { + "description": "The price of the product", + + "type": "number", + + "exclusiveMinimum": 0 + }, + + "tags": { + "description": "Tags for the product", + + "type": "array", + + "items": { + "type": "string" + }, + + "minItems": 1, + + "uniqueItems": true + } + }, + + "required": ["productId", "productName", "price"] +} diff --git a/tests/test_base.py b/tests/test_base.py index eb1932fd59fdac405a8bafefd21995c78be45b23_dGVzdHMvdGVzdF9iYXNlLnB5..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF9iYXNlLnB5 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 2024 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,6 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## +from odoo import tests # type: ignore[import-untyped] +from odoo.exceptions import UserError # type: ignore[import-untyped] @@ -20,6 +22,5 @@ -from odoo import tests -from odoo.addons.converter import ( +from .. import ( Constant, Field, Model, @@ -27,7 +28,6 @@ message_to_odoo, relation, ) -from odoo.exceptions import UserError class Test(tests.TransactionCase): @@ -43,8 +43,8 @@ self.assertEqual("a", converter.odoo_to_message(self.env["res.users"])) def test_unset_field(self): - # this tests when a relationnal substitution - # is substitued on a record that has not + # this tests when a relational substitution + # is substituted on a record that has not # the relation set self.assertTrue(self.active_user.create_uid) @@ -60,7 +60,6 @@ Field("name"), ) converter = Model( - None, { "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 eb1932fd59fdac405a8bafefd21995c78be45b23_dGVzdHMvdGVzdF9jb252ZXJ0ZXJzLnB5..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF9jb252ZXJ0ZXJzLnB5 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -18,7 +18,7 @@ # ############################################################################## -from odoo import tests +from odoo import tests # type: ignore[import-untyped] from ..base import Skip from ..field import Field @@ -99,5 +99,4 @@ """ return Model( # TODO add a schema to test validation - "test-type", { @@ -103,6 +102,6 @@ { - "id": Xref(), + "id": Xref(include_module_name=True), "country_code": RelationToOne( "country_id", None, KeyField("code", "res.country") ), "name": Field("name"), @@ -105,6 +104,8 @@ "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) + ), }, @@ -110,2 +111,3 @@ }, + __type__="test-type", ) diff --git a/tests/test_field.py b/tests/test_field.py index eb1932fd59fdac405a8bafefd21995c78be45b23_dGVzdHMvdGVzdF9maWVsZC5weQ==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF9maWVsZC5weQ== 100644 --- a/tests/test_field.py +++ b/tests/test_field.py @@ -20,8 +20,9 @@ from typing import Any -from odoo import tests -from odoo.addons.converter import Field, TranslatedSelection +from odoo import tests # type: ignore[import-untyped] + +from .. import Field, TranslatedSelection class Test(tests.TransactionCase): diff --git a/tests/test_ir_model.py b/tests/test_ir_model.py index eb1932fd59fdac405a8bafefd21995c78be45b23_dGVzdHMvdGVzdF9pcl9tb2RlbC5weQ==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF9pcl9tb2RlbC5weQ== 100644 --- a/tests/test_ir_model.py +++ b/tests/test_ir_model.py @@ -18,7 +18,7 @@ # ############################################################################## -from odoo.tests import TransactionCase, tagged +from odoo.tests import TransactionCase, tagged # type: ignore[import-untyped] @tagged("post_install", "-at_install") diff --git a/tests/test_mail_template.py b/tests/test_mail_template.py index eb1932fd59fdac405a8bafefd21995c78be45b23_dGVzdHMvdGVzdF9tYWlsX3RlbXBsYXRlLnB5..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF9tYWlsX3RlbXBsYXRlLnB5 100644 --- a/tests/test_mail_template.py +++ b/tests/test_mail_template.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2023 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2023, 2024 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 @@ -18,7 +18,8 @@ # ############################################################################## -from odoo import tests -from odoo.addons.converter import MailTemplate +from odoo import tests # type: ignore[import-untyped] + +from ..mail_template import MailTemplate @@ -23,5 +24,6 @@ +@tests.tagged("-standard", "odoo_addons_mail") class Test(tests.TransactionCase): """Test converter that wraps ``mail.template::_render_template``.""" diff --git a/tests/test_relation.py b/tests/test_relation.py index eb1932fd59fdac405a8bafefd21995c78be45b23_dGVzdHMvdGVzdF9yZWxhdGlvbi5weQ==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF9yZWxhdGlvbi5weQ== 100644 --- a/tests/test_relation.py +++ b/tests/test_relation.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2021 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 @@ -19,8 +19,9 @@ ############################################################################## from typing import Any -from odoo import tests -from odoo.addons.converter import ( +from odoo import tests # type: ignore[import-untyped] + +from .. import ( Field, Model, RelationToMany, @@ -35,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 @@ -51,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) @@ -55,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) @@ -57,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) @@ -74,9 +77,8 @@ # This converter wraps a user and adds info from its related partner. converter = Model( - None, { "partner": RelationToOne( "partner_id", "res.partner", Model( @@ -78,11 +80,10 @@ { "partner": RelationToOne( "partner_id", "res.partner", Model( - "", { "color": Field("color"), "name": Field("name"), "xref": Xref("test"), }, @@ -84,8 +85,9 @@ { "color": Field("color"), "name": Field("name"), "xref": Xref("test"), }, + __type__="partner", ), ), "xref": Xref("base"), @@ -96,5 +98,5 @@ old_partner = user.partner_id # Run our message reception. - message = { + message: dict[str, Any] = { "partner": { @@ -100,4 +102,5 @@ "partner": { + "__type__": "partner", "color": 2, "name": "TEST", "xref": "new_partner_converter", @@ -106,7 +109,7 @@ } message_to_odoo(self.env, message, self.env["res.users"], converter) - # Ensure a new partner got created and that is has an xref (post hook). + # Ensure a new partner got created and that it has an xref (post hook). new_partner = self.env.ref("test.new_partner_converter") self.assertEqual(user.partner_id, new_partner) self.assertNotEqual(new_partner, old_partner) @@ -126,9 +129,8 @@ # This converter wraps a user and adds info from its related partner. converter = Model( - None, { "users": RelationToMany( "user_ids", "res.users", Model( @@ -130,10 +132,9 @@ { "users": RelationToMany( "user_ids", "res.users", Model( - "user", { "email": Field("email"), "xref": Xref("base"), }, @@ -136,7 +137,8 @@ { "email": Field("email"), "xref": Xref("base"), }, + __type__="user", ), ), "xref": Xref("base"), @@ -165,3 +167,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 eb1932fd59fdac405a8bafefd21995c78be45b23_dGVzdHMvdGVzdF9zd2l0Y2gucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF9zd2l0Y2gucHk= 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -18,7 +18,7 @@ # ############################################################################## -from odoo import tests +from odoo import tests # type: ignore[import-untyped] from .. import Field, Model, Switch @@ -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__", ), ), ] diff --git a/tests/test_validate.py b/tests/test_validate.py new file mode 100644 index 0000000000000000000000000000000000000000..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF92YWxpZGF0ZS5weQ== --- /dev/null +++ b/tests/test_validate.py @@ -0,0 +1,64 @@ +############################################################################## +# +# Converter Odoo module +# Copyright © 2024 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 +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +############################################################################## +import json + +from odoo import tests # type: ignore[import-untyped] + +from ..validate import Validator + + +class TestValidate(tests.TransactionCase): + def test_validate(self): + validator = Validator( + "odoo.addons.converter.tests.schemas", "https://example.com/{}.schema.json" + ) + validator.initialize() + validator.validate( + "product", + json.loads("""{ + "productId": 1, + "productName": "An ice sculpture", + "price": 12.5, + "tags": [ + "cold", + "ice" + ] +}"""), + ) + + def test_validate_dir(self): + validator = Validator( + "odoo.addons.converter.tests", + "https://example.com/{}.schema.json", + "schemas_dir", + ) + validator.initialize() + validator.validate( + "product", + json.loads("""{ + "productId": 1, + "productName": "An ice sculpture", + "price": 12.5, + "tags": [ + "cold", + "ice" + ] +}"""), + ) diff --git a/validate.py b/validate.py index eb1932fd59fdac405a8bafefd21995c78be45b23_dmFsaWRhdGUucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dmFsaWRhdGUucHk= 100644 --- a/validate.py +++ b/validate.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 2024 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,5 +17,4 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## - import json @@ -21,2 +20,3 @@ import json +import logging import os @@ -22,2 +22,6 @@ import os +from collections.abc import Callable +from enum import Enum +from importlib import import_module +from typing import Any @@ -23,4 +27,7 @@ -import fastjsonschema -import odoo.addons +import fastjsonschema # type: ignore[import-untyped] +from odoo.exceptions import UserError # type: ignore[import-untyped] + +_logger = logging.getLogger(__name__) + @@ -26,10 +33,13 @@ -VALIDATION_SKIP = "skip" -VALIDATION_SOFT = "soft" -VALIDATION_STRICT = "strict" +class Validation(str, Enum): + """Type of validation""" + + SKIP = "skip" + SOFT = "soft" + STRICT = "strict" class NotInitialized(Exception): pass @@ -30,9 +40,16 @@ class NotInitialized(Exception): pass +def _add_schema(schemas, schema): + if "$id" in schema: + schemas[schema["$id"]] = schema + else: + _logger.warning("Schema without $id (schema ignored)") + + class Validator: def __init__( self, @@ -36,6 +53,5 @@ class Validator: def __init__( self, - repository_module_name: str, - repository: str, + package_name: str, default_url_pattern: str, @@ -41,2 +57,3 @@ default_url_pattern: str, + directory: str | None = None, ): @@ -42,5 +59,10 @@ ): - self.repository_module_name = repository_module_name - self.repository = repository + """ + :param package_name: Package where the schema can be found + :param default_url_pattern: pattern for url ({} will be replaced by $id) + :param directory: directory to search for json, not used if a get_schema is + provided in the package. + """ + self.package_name = package_name # exemple "https://annonces-legales.fr/xbus/schemas/v1/{}.schema.json" self.default_url_pattern = default_url_pattern @@ -45,5 +67,5 @@ # exemple "https://annonces-legales.fr/xbus/schemas/v1/{}.schema.json" self.default_url_pattern = default_url_pattern - self.validators = {} + self.validators: dict[str, Callable] = {} self.initialized = False self.encoding = "UTF-8" @@ -48,4 +70,5 @@ self.initialized = False self.encoding = "UTF-8" + self.directory = directory def initialize(self) -> None: @@ -50,22 +73,30 @@ def initialize(self) -> None: - repo_module_basepath = os.path.dirname( - getattr(odoo.addons, self.repository_module_name).__file__ - ) - - # Read local schema definitions. - schemas = {} - schema_search_path = os.path.abspath( - os.path.join(repo_module_basepath, self.repository) - ) - for root, _dirs, files in os.walk(schema_search_path): - for fname in files: - fpath = os.path.join(root, fname) - if fpath.endswith((".json",)): - with open(fpath, encoding=self.encoding) as schema_fd: - schema = json.load(schema_fd) - if "$id" in schema: - schemas[schema["$id"]] = schema + if self.initialized: + return + schemas: dict[str, Any] = {} + module = import_module(self.package_name) + if hasattr(module, "get_schemas"): + for schema in module.get_schemas(): + _add_schema(schemas, schema) + else: + if module.__file__ is None: + # XXX maybe not the best type of error + raise UserError("Module %s has no file", self.package_name) + # Fallback on searching schema json files + schema_search_path = os.path.dirname(module.__file__) + schema_search_path = os.path.abspath( + os.path.join(schema_search_path, self.directory) + if self.directory is not None + else schema_search_path + ) + for root, _dirs, files in os.walk(schema_search_path): + for fname in files: + fpath = os.path.join(root, fname) + if fpath.endswith((".json",)): + with open(fpath, encoding=self.encoding) as schema_fd: + schema = json.load(schema_fd) + _add_schema(schemas, schema) # Prepare validators for each schema. We add an HTTPS handler that # points back to our schema definition cache built above. @@ -77,7 +108,7 @@ ) self.initialized = True - def validate(self, schema_id, payload) -> None: + def validate(self, schema_id: str, payload) -> None: if not self.initialized: raise NotInitialized("please call the initialize() method") diff --git a/xref.py b/xref.py index eb1932fd59fdac405a8bafefd21995c78be45b23_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 models +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 Converter, NewinstanceType -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??? @@ -27,8 +38,8 @@ # TODO dans quel cas ça ne pourrait pas être un instance getter??? -class Xref(Converter): +class Xref(PostHookConverter): """This converter represents an external reference, using the standard xmlid with a custom module name. """ @@ -31,8 +42,18 @@ """This converter represents an external reference, using the standard xmlid with a custom module name. """ - def __init__(self, module: str = _XREF_IMD_MODULE, is_instance_getter: bool = True): + def __init__( + 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 @@ -36,4 +57,6 @@ 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 @@ -39,4 +62,4 @@ - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: if not instance: return "" @@ -41,5 +64,5 @@ 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 ) @@ -45,3 +68,8 @@ ) + 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( @@ -46,8 +74,19 @@ def get_instance( - self, odoo_env, message_data - ) -> None | models.Model | NewinstanceType: - return odoo_env.ref( - ".".join([self._module, message_data]), raise_if_not_found=False + 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( + 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, name, module=module, only_when_missing=True ) @@ -52,8 +91,68 @@ ) - def post_hook(self, instance: models.Model, message_data): - # add xmlid to the newly created object - instance.env["ir.model.data"].set_xmlid( - instance, message_data, module=self._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), + ] + ) ) @@ -58,4 +157,35 @@ ) - def is_instance_getter(self) -> bool: - return self._is_instance_getter + 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