# Copyright 2017 Camptocamp SA # Copyright 2019 ACSONE SA/NV # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) import logging from collections import OrderedDict, defaultdict from contextlib import ExitStack from marshmallow import INCLUDE from odoo.api import Environment from odoo.tools import LastOrderedSet, OrderedSet _logger = logging.getLogger(__name__) try: import marshmallow from marshmallow_objects.models import Model as MarshmallowModel, ModelMeta except ImportError: _logger.debug("Cannot import 'marshmallow_objects'.") # The Cache size represents the number of items, so the number # of datamodels (include abstract datamodels) we will keep in the LRU # cache. We would need stats to know what is the average but this is a bit # early. DEFAULT_CACHE_SIZE = 512 # this is duplicated from odoo.models.MetaModel._get_addon_name() which we # unfortunately can't use because it's an instance method and should have been # a @staticmethod def _get_addon_name(full_name): # The (Odoo) module name can be in the ``odoo.addons`` namespace # or not. For instance, module ``sale`` can be imported as # ``odoo.addons.sale`` (the right way) or ``sale`` (for backward # compatibility). module_parts = full_name.split(".") if len(module_parts) > 2 and module_parts[:2] == ["odoo", "addons"]: addon_name = module_parts[2] else: addon_name = module_parts[0] return addon_name def _get_nested_schemas(schema): res = [schema] for field in schema.fields.values(): if getattr(field, "schema", None): res += _get_nested_schemas(field.schema) return res class DatamodelDatabases(dict): """Holds a registry of datamodels for each database""" class DatamodelRegistry(object): """Store all the datamodel and allow to retrieve them by name The key is the ``_name`` of the datamodels. This is an OrderedDict, because we want to keep the registration order of the datamodels, addons loaded first have their datamodels found first. The :attr:`ready` attribute must be set to ``True`` when all the datamodels are loaded. """ def __init__(self, cachesize=DEFAULT_CACHE_SIZE): self._datamodels = OrderedDict() self._loaded_modules = set() self.ready = False def __getitem__(self, key): return self._datamodels[key] def __setitem__(self, key, value): self._datamodels[key] = value def __contains__(self, key): return key in self._datamodels def get(self, key, default=None): return self._datamodels.get(key, default) def __iter__(self): return iter(self._datamodels) def load_datamodels(self, module): if module in self._loaded_modules: return for datamodel_class in MetaDatamodel._modules_datamodels[module]: datamodel_class._build_datamodel(self) self._loaded_modules.add(module) # We will store a DatamodeltRegistry per database here, # it will be cleared and updated when the odoo's registry is rebuilt _datamodel_databases = DatamodelDatabases() @marshmallow.post_load def __make_object__(self, data, **kwargs): datamodel = self._env.datamodels[self._datamodel_name] return datamodel(__post_load__=True, __schema__=self, **data) class MetaDatamodel(ModelMeta): """Metaclass for Datamodel Every new :class:`Datamodel` will be added to ``_modules_datamodels``, that will be used by the datamodel builder. """ _modules_datamodels = defaultdict(list) def __init__(self, name, bases, attrs): if not self._register: self._register = True super(MetaDatamodel, self).__init__(name, bases, attrs) return # If datamodels are declared in tests, exclude them from the # "datamodels of the addon" list. If not, when we use the # "load_datamodels" method, all the test datamodels would be loaded. # This should never be an issue when running the app normally, as the # Python tests should never be executed. But this is an issue when a # test creates a test datamodels for the purpose of the test, then a # second tests uses the "load_datamodels" to load all the addons of the # module: it will load the datamodel of the previous test. if "tests" in self.__module__.split("."): return if not hasattr(self, "_module"): self._module = _get_addon_name(self.__module__) self._modules_datamodels[self._module].append(self) def __call__(self, *args, **kwargs): """Allow to set any field (including 'dump_only') at instantiation This is not an issue thanks to cleanup during (de)serialization """ kwargs["unknown"] = kwargs.get("unknown", INCLUDE) return super().__call__(*args, **kwargs) class Datamodel(MarshmallowModel, metaclass=MetaDatamodel): """Main Datamodel Model All datamodels have a Python inheritance either on :class:`Datamodel`. Inheritance mechanism The inheritance mechanism is like the Odoo's one for Models. Each datamodel has a ``_name``. This is the absolute minimum in a Datamodel class. :: from marshmallow import fields from odoo.addons.datamodel.core import Datamodel class MyDatamodel(Datamodel): _name = 'my.datamodel' name = fields.String() Every datamodel implicitly inherit from the `'base'` datamodel. There are two close but distinct inheritance types, which look familiar if you already know Odoo. The first uses ``_inherit`` with an existing name, the name of the datamodel we want to extend. With the following example, ``my.datamodel`` is now able to speak and to yell. :: class MyDatamodel(Datamodel): # name of the class does not matter _inherit = 'my.datamodel' The second has a different ``_name``, it creates a new datamodel, including the behavior of the inherited datamodel, but without modifying it. :: class AnotherDatamodel(Datamodel): _name = 'another.datamodel' _inherit = 'my.datamodel' age = fields.Int() """ _register = False _env = None # Odoo Environment # used for inheritance _name = None #: Name of the datamodel #: Name or list of names of the datamodel(s) to inherit from _inherit = None def __init__(self, context=None, partial=None, env=None, **kwargs): self._env = env or type(self)._env super().__init__(context=context, partial=partial, **kwargs) @property def env(self): return self._env @classmethod def get_schema(cls, **kwargs): """ Get a marshmallow schema instance :param kwargs: :return: """ return cls.__get_schema_class__(**kwargs) @classmethod def validate(cls, data, context=None, many=None, partial=None, unknown=None): schema = cls.__get_schema_class__( context=context, partial=partial, unknown=unknown ) all_schemas = _get_nested_schemas(schema) with ExitStack() as stack: # propagate 'unknown' to each nested schema during validate for nested_schema in all_schemas: stack.enter_context(cls.propagate_unknwown(nested_schema, unknown)) return schema.validate(data, many=many, partial=partial) @classmethod def _build_datamodel(cls, registry): """Instantiate a given Datamodel in the datamodels registry. This method is called at the end of the Odoo's registry build. The caller is :meth:`datamodel.builder.DatamodelBuilder.load_datamodels`. It generates new classes, which will be the Datamodel classes we will be using. The new classes are generated following the inheritance of ``_inherit``. It ensures that the ``__bases__`` of the generated Datamodel classes follow the ``_inherit`` chain. Once a Datamodel class is created, it adds it in the Datamodel Registry (:class:`DatamodelRegistry`), so it will be available for lookups. At the end of new class creation, a hook method :meth:`_complete_datamodel_build` is called, so you can customize further the created datamodels. The following code is roughly the same than the Odoo's one for building Models. """ # In the simplest case, the datamodel's registry class inherits from # cls and the other classes that define the datamodel in a flat # hierarchy. The registry contains the instance ``datamodel`` (on the # left). Its class, ``DatamodelClass``, carries inferred metadata that # is shared between all the datamodel's instances for this registry # only. # # class A1(Datamodel): Datamodel # _name = 'a' / | \ # A3 A2 A1 # class A2(Datamodel): \ | / # _inherit = 'a' DatamodelClass # # class A3(Datamodel): # _inherit = 'a' # # When a datamodel is extended by '_inherit', its base classes are # modified to include the current class and the other inherited # datamodel classes. # Note that we actually inherit from other ``DatamodelClass``, so that # extensions to an inherited datamodel are immediately visible in the # current datamodel class, like in the following example: # # class A1(Datamodel): # _name = 'a' Datamodel # / / \ \ # class B1(Datamodel): / A2 A1 \ # _name = 'b' / \ / \ # B2 DatamodelA B1 # class B2(Datamodel): \ | / # _name = 'b' \ | / # _inherit = ['b', 'a'] \ | / # DatamodelB # class A2(Datamodel): # _inherit = 'a' # determine inherited datamodels parents = cls._inherit if isinstance(parents, str): parents = [parents] elif parents is None: parents = [] if cls._name in registry and not parents: raise TypeError( "Datamodel %r (in class %r) already exists. " "Consider using _inherit instead of _name " "or using a different _name." % (cls._name, cls) ) # determine the datamodel's name name = cls._name or (len(parents) == 1 and parents[0]) if not name: raise TypeError("Datamodel %r must have a _name" % cls) # all datamodels except 'base' implicitly inherit from 'base' if name != "base": parents = list(parents) + ["base"] # create or retrieve the datamodel's class if name in parents: if name not in registry: raise TypeError("Datamodel %r does not exist in registry." % name) # determine all the classes the datamodel should inherit from bases = LastOrderedSet([cls]) for parent in parents: if parent not in registry: raise TypeError( "Datamodel %r inherits from non-existing datamodel %r." % (name, parent) ) parent_class = registry[parent] if parent == name: for base in parent_class.__bases__: bases.add(base) else: bases.add(parent_class) parent_class._inherit_children.add(name) if name in parents: DatamodelClass = registry[name] # Add the new bases to the existing model since the class into # the registry could already be used into an inherit DatamodelClass.__bases__ = tuple(bases) # We must update the marshmallow schema on the existing datamodel # class to include those inherited parent_schemas = [] for parent in bases: if issubclass(parent, MarshmallowModel): parent_schemas.append(parent.__schema_class__) schema_class = type(name + "Schema", tuple(parent_schemas), {}) DatamodelClass.__schema_class__ = schema_class else: attrs = { "_name": name, "_register": False, # names of children datamodel "_inherit_children": OrderedSet(), } if name == "base": attrs["_registry"] = registry DatamodelClass = type(name, tuple(bases), attrs) setattr(DatamodelClass.__schema_class__, "_registry", registry) # noqa: B010 setattr(DatamodelClass.__schema_class__, "_datamodel_name", name) # noqa: B010 setattr( # noqa: B010 DatamodelClass.__schema_class__, "__make_object__", __make_object__ ) DatamodelClass._complete_datamodel_build() registry[name] = DatamodelClass return DatamodelClass @classmethod def _complete_datamodel_build(cls): """Complete build of the new datamodel class After the datamodel has been built from its bases, this method is called, and can be used to customize the class before it can be used. Nothing is done in the base Datamodel, but a Datamodel can inherit the method to add its own behavior. """ # makes the datamodels registry available on env class DataModelFactory(object): """Factory for datamodels This factory ensures the propagation of the environment to the instanciated datamodels and related schema. """ __slots__ = ("env", "registry") def __init__(self, env, registry): self.env = env self.registry = registry def __getitem__(self, key): model = self.registry[key] model._env = self.env @classmethod def __get_schema_class__(cls, **kwargs): cls = cls.__schema_class__(**kwargs) cls._env = self.env return cls model.__get_schema_class__ = __get_schema_class__ return model @property def datamodels(self): if not hasattr(self, "_datamodels_factory"): factory = DataModelFactory(self, _datamodel_databases.get(self.cr.dbname)) self._datamodels_factory = factory return self._datamodels_factory Environment.datamodels = datamodels