############################################################################## # # Converter Odoo module # 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 # 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 logging import traceback from collections import OrderedDict from collections.abc import Iterable, Mapping from typing import Any, Final from odoo import _, api, models # type: ignore[import-untyped] from odoo.exceptions import UserError # type: ignore[import-untyped] from .base import ( Context, ContextBuilder, Converter, Newinstance, NewinstanceType, PostHookConverter, SkipType, build_context, ) from .validate import NotInitialized, Validation, Validator try: from fastjsonschema import JsonSchemaException # type: ignore[import-untyped] except ImportError: # Ignore no-redef, added for compatibility class JsonSchemaException(Exception): # type: ignore[no-redef] """Custom error in case of missing optional requirement""" _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, 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: Validation = Validation.SKIP, context: ContextBuilder | None = None, datatype: str | None = None, __type__: str | None = None, ): """ :param datatype: datatype to use. Usually used with None __type__. """ super().__init__() self._type: str | None = __type__ self._converters: Mapping[str, Converter] = converters self._post_hooks_converters: dict[str, PostHookConverter] = {} self._jsonschema: str | None = json_schema 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 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: self._get_instance = key if isinstance(converter, PostHookConverter): self._post_hooks_converters[key] = converter def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: ctx = build_context(instance, ctx, self.context) message_data = {} if self._type is not None: message_data["__type__"] = self._type errors = [] for key in self._converters: try: value = self._converters[key].odoo_to_message(instance, ctx) except Exception as e: errors.append( {"key": key, "traceback": "".join(traceback.format_exception(e))} ) continue if not isinstance(value, SkipType): message_data[key] = value if len(errors) != 0: formatted_errors = "\n\n".join( [f"{error['traceback']}Key: {error['key']}" for error in errors] ) raise UserError( _( "Got unexpected errors while parsing substitutions:\n%s", formatted_errors, ) ) if self.merge_with: for conv in self.merge_with: value = conv.odoo_to_message(instance, ctx) if isinstance(value, SkipType): continue message_data.update(value) if self.validation != Validation.SKIP and self._jsonschema is not None: if self.validator: try: self.validator.validate(self._jsonschema, message_data) except (NotInitialized, JsonSchemaException): _logger.warning("Validation failed", exc_info=True) if self.validation == Validation.STRICT: raise elif self.validation == Validation.STRICT: raise MissingRequiredValidatorException() return message_data def message_to_odoo( self, odoo_env: api.Environment, phase: str, message_value: Any, instance: models.BaseModel, value_present: bool = True, ) -> 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, 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 attribute_vals = self._converters[key].message_to_odoo( odoo_env, phase, value, instance, message_value and key in message_value, ) 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 ) if isinstance(value, SkipType): 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 ) -> 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( odoo_env, message_data[self._get_instance] ) if instance is None: instance = Newinstance return instance return None def post_hook(self, instance: models.BaseModel, message_data): for key in self._post_hooks_converters: if key in message_data: 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]: 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