diff --git a/datamodel/README.rst b/datamodel/README.rst new file mode 100644 index 000000000..820035156 --- /dev/null +++ b/datamodel/README.rst @@ -0,0 +1,187 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +========= +Datamodel +========= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:5411d4f742eb933a4d05f5f6e1784a7ddc042e7f22b1c08d535e225c306a6955 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github + :target: https://github.com/OCA/rest-framework/tree/19.0/datamodel + :alt: OCA/rest-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/rest-framework-19-0/rest-framework-19-0-datamodel + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This addon allows you to define simple data models supporting +serialization/deserialization to/from json + +Datamodels are `Marshmallow +models `__ classes that +can be inherited as Odoo Models. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To define your own datamodel you just need to create a class that +inherits from ``odoo.addons.datamodel.core.Datamodel`` + +.. code:: python + + from marshmallow import fields + + from odoo.addons.base_rest import restapi + from odoo.addons.component.core import Component + from odoo.addons.datamodel.core import Datamodel + + + class PartnerShortInfo(Datamodel): + _name = "partner.short.info" + + id = fields.Integer(required=True, allow_none=False) + name = fields.String(required=True, allow_none=False) + + class PartnerInfo(Datamodel): + _name = "partner.info" + _inherit = "partner.short.info" + + street = fields.String(required=True, allow_none=False) + street2 = fields.String(required=False, allow_none=True) + zip_code = fields.String(required=True, allow_none=False) + city = fields.String(required=True, allow_none=False) + phone = fields.String(required=False, allow_none=True) + is_componay = fields.Boolean(required=False, allow_none=False) + +As for odoo models, you can extend the base datamodel by inheriting of +base. + +.. code:: python + + class Base(Datamodel): + _inherit = "base" + + def _my_method(self): + pass + +Datamodels are available through the datamodels registry provided by the +Odoo's environment. + +.. code:: python + + class ResPartner(Model): + _inherit = "res.partner" + + def _to_partner_info(self): + PartnerInfo = self.env.datamodels["partner.info"] + partner_info = PartnerInfo(partial=True) + partner_info.id = partner.id + partner_info.name = partner.name + partner_info.street = partner.street + partner_info.street2 = partner.street2 + partner_info.zip_code = partner.zip + partner_info.city = partner.city + partner_info.phone = partner.phone + partner_info.is_company = partner.is_company + return partner_info + +The Odoo's environment is also available into the datamodel instance. + +.. code:: python + + class MyDataModel(Datamodel): + _name = "my.data.model" + + def _my_method(self): + partners = self.env["res.partner"].search([]) + +.. warning:: + + The env property into a Datamodel instance is mutable. IOW, you can't + rely on information (context, user) provided by the environment. The + env property is a helper property that give you access to the odoo's + registry and must be use with caution. + +Known issues / Roadmap +====================== + +The +`roadmap `__ +and `known +issues `__ +can be found on GitHub. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ACSONE SA/NV + +Contributors +------------ + +- Laurent Mignon + +- `Tecnativa `__: + + - Carlos Roca + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-lmignon| image:: https://github.com/lmignon.png?size=40px + :target: https://github.com/lmignon + :alt: lmignon + +Current `maintainer `__: + +|maintainer-lmignon| + +This module is part of the `OCA/rest-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/datamodel/__init__.py b/datamodel/__init__.py new file mode 100644 index 000000000..76665ca2a --- /dev/null +++ b/datamodel/__init__.py @@ -0,0 +1,2 @@ +from . import builder +from . import datamodels diff --git a/datamodel/__manifest__.py b/datamodel/__manifest__.py new file mode 100644 index 000000000..ddcd42d59 --- /dev/null +++ b/datamodel/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2019 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +{ + "name": "Datamodel", + "summary": """ + This addon allows you to define simple data models supporting + serialization/deserialization""", + "version": "19.0.1.0.0", + "license": "LGPL-3", + "development_status": "Beta", + "author": "ACSONE SA/NV, Odoo Community Association (OCA)", + "maintainers": ["lmignon"], + "website": "https://github.com/OCA/rest-framework", + "external_dependencies": { + "python": ["marshmallow<4.0.0", "marshmallow-objects>=2.0.0"] + }, +} diff --git a/datamodel/builder.py b/datamodel/builder.py new file mode 100644 index 000000000..80a64fe3e --- /dev/null +++ b/datamodel/builder.py @@ -0,0 +1,97 @@ +# Copyright 2017 Camptocamp SA +# Copyright 2019 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +""" + +Datamodels Builder +================== + +Build the datamodels at the build of a registry. + +""" + +from odoo import models, modules + +from .core import DEFAULT_CACHE_SIZE, DatamodelRegistry, _datamodel_databases + + +class DatamodelBuilder(models.AbstractModel): + """Build the datamodel classes + + And register them in a global registry. + + Every time an Odoo registry is built, the know datamodels are cleared and + rebuilt as well. The Datamodel classes are built using the same mechanism + than Odoo's Models: a final class is created, taking every Datamodels with + a ``_name`` and applying Datamodels with an ``_inherits`` upon them. + + The final Datamodel classes are registered in global registry. + + This class is an Odoo model, allowing us to hook the build of the + datamodels at the end of the Odoo's registry loading, using + ``_register_hook``. This method is called after all modules are loaded, so + we are sure that we have all the datamodels Classes and in the correct + order. + + """ + + _name = "datamodel.builder" + _description = "Datamodel Builder" + + _datamodels_registry_cache_size = DEFAULT_CACHE_SIZE + + def _register_hook(self): + # This method is called by Odoo when the registry is built, + # so in case the registry is rebuilt (cache invalidation, ...), + # we have to rebuild the datamodels. We use a new + # registry so we have an empty cache and we'll add datamodels in it. + datamodels_registry = self._init_global_registry() + self.build_registry(datamodels_registry) + datamodels_registry.ready = True + + def _init_global_registry(self): + datamodels_registry = DatamodelRegistry( + cachesize=self._datamodels_registry_cache_size + ) + _datamodel_databases[self.env.cr.dbname] = datamodels_registry + return datamodels_registry + + def build_registry(self, datamodels_registry, states=None, exclude_addons=None): + if not states: + states = ("installed", "to upgrade") + # lookup all the installed (or about to be) addons and generate + # the graph, so we can load the datamodels following the order + # of the addons' dependencies + graph = modules.module_graph.ModuleGraph(self.env.cr) + graph.extend(["base"]) + + query = "SELECT name FROM ir_module_module WHERE state IN %s " + params = [tuple(states)] + if exclude_addons: + query += " AND name NOT IN %s " + params.append(tuple(exclude_addons)) + self.env.cr.execute(query, params) + + module_list = [name for (name,) in self.env.cr.fetchall() if name not in graph] + graph.extend(module_list) + + for module in graph: + self.load_datamodels(module.name, datamodels_registry=datamodels_registry) + + def load_datamodels(self, module, datamodels_registry=None): + """Build every datamodel known by MetaDatamodel for an odoo module + + The final datamodel (composed by all the Datamodel classes in this + module) will be pushed into the registry. + + :param module: the name of the addon for which we want to load + the datamodels + :type module: str | unicode + :param registry: the registry in which we want to put the Datamodel + :type registry: :py:class:`~.core.DatamodelRegistry` + """ + datamodels_registry = ( + datamodels_registry or _datamodel_databases[self.env.cr.dbname] + ) + datamodels_registry.load_datamodels(module) diff --git a/datamodel/core.py b/datamodel/core.py new file mode 100644 index 000000000..d1bd44f27 --- /dev/null +++ b/datamodel/core.py @@ -0,0 +1,428 @@ +# 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 + from marshmallow_objects.models import 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: + """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().__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): + """Current datamodels registry""" + 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( + f"Datamodel {cls._name} (in class {cls}) already exists. " + "Consider using _inherit instead of _name " + "or using a different _name." + ) + + # determine the datamodel's name + name = cls._name or (len(parents) == 1 and parents[0]) + + if not name: + raise TypeError(f"Datamodel {cls} must have a _name") + + # 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(f"Datamodel {name} does not exist in registry.") + + # determine all the classes the datamodel should inherit from + bases = LastOrderedSet([cls]) + for parent in parents: + if parent not in registry: + raise TypeError( + f"Datamodel {name} inherits from non-existing datamodel {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: + """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 diff --git a/datamodel/datamodels/__init__.py b/datamodel/datamodels/__init__.py new file mode 100644 index 000000000..0e4444933 --- /dev/null +++ b/datamodel/datamodels/__init__.py @@ -0,0 +1 @@ +from . import base diff --git a/datamodel/datamodels/base.py b/datamodel/datamodels/base.py new file mode 100644 index 000000000..1d0dc1225 --- /dev/null +++ b/datamodel/datamodels/base.py @@ -0,0 +1,15 @@ +# Copyright 2019 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +from ..core import Datamodel + + +class BaseDatamodel(Datamodel): + """This is the base datamodel for every datamodel + + It is implicitely inherited by all datamodels. + + All your base are belong to us + """ + + _name = "base" diff --git a/datamodel/fields.py b/datamodel/fields.py new file mode 100644 index 000000000..f5dce75e5 --- /dev/null +++ b/datamodel/fields.py @@ -0,0 +1,51 @@ +# Copyright 2019 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) +""" + +Fields +===== + +Create a single place for all fields defined for datamodels + +""" + +import logging + +from .core import Datamodel + +_logger = logging.getLogger(__name__) + +try: + from marshmallow.fields import * # noqa: F403,F401 + from marshmallow.fields import Nested +except ImportError: + Nested = object + _logger.debug("Cannot import 'marshmallow'.") + + +class NestedModel(Nested): + def __init__(self, nested, **kwargs): + self.datamodel_name = nested + super().__init__(None, **kwargs) + + @property + def schema(self): + if not self.nested: + # Get the major parent to avoid error of _env does not exist + super_parent = None + parent = self + while not super_parent: + if not hasattr(parent, "parent"): + super_parent = parent + break + parent = parent.parent + self.nested = super_parent._env.datamodels[ + self.datamodel_name + ].__schema_class__ + self.nested._env = super_parent._env + return super().schema + + def _deserialize(self, value, attr, data, **kwargs): + if isinstance(value, Datamodel): + return value + return super()._deserialize(value, attr, data, **kwargs) diff --git a/datamodel/i18n/datamodel.pot b/datamodel/i18n/datamodel.pot new file mode 100644 index 000000000..970d66466 --- /dev/null +++ b/datamodel/i18n/datamodel.pot @@ -0,0 +1,19 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * datamodel +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: datamodel +#: model:ir.model,name:datamodel.model_datamodel_builder +msgid "Datamodel Builder" +msgstr "" diff --git a/datamodel/pyproject.toml b/datamodel/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/datamodel/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/datamodel/readme/CONTRIBUTORS.md b/datamodel/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..e8f4fc950 --- /dev/null +++ b/datamodel/readme/CONTRIBUTORS.md @@ -0,0 +1,5 @@ +- Laurent Mignon \<\> + +- [Tecnativa](https://www.tecnativa.com): + + > - Carlos Roca diff --git a/datamodel/readme/DESCRIPTION.md b/datamodel/readme/DESCRIPTION.md new file mode 100644 index 000000000..023a60906 --- /dev/null +++ b/datamodel/readme/DESCRIPTION.md @@ -0,0 +1,6 @@ +This addon allows you to define simple data models supporting +serialization/deserialization to/from json + +Datamodels are [Marshmallow +models](https://github.com/sv-tools/marshmallow-objects) classes that +can be inherited as Odoo Models. diff --git a/datamodel/readme/ROADMAP.md b/datamodel/readme/ROADMAP.md new file mode 100644 index 000000000..09a4f4277 --- /dev/null +++ b/datamodel/readme/ROADMAP.md @@ -0,0 +1,5 @@ +The +[roadmap](https://github.com/OCA/rest-framework/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement+label%3Adatamodel) +and [known +issues](https://github.com/OCA/rest-framework/issues?q=is%3Aopen+is%3Aissue+label%3Abug+label%3Adatamodel) +can be found on GitHub. diff --git a/datamodel/readme/USAGE.md b/datamodel/readme/USAGE.md new file mode 100644 index 000000000..40714e886 --- /dev/null +++ b/datamodel/readme/USAGE.md @@ -0,0 +1,76 @@ +To define your own datamodel you just need to create a class that +inherits from `odoo.addons.datamodel.core.Datamodel` + +``` python +from marshmallow import fields + +from odoo.addons.base_rest import restapi +from odoo.addons.component.core import Component +from odoo.addons.datamodel.core import Datamodel + + +class PartnerShortInfo(Datamodel): + _name = "partner.short.info" + + id = fields.Integer(required=True, allow_none=False) + name = fields.String(required=True, allow_none=False) + +class PartnerInfo(Datamodel): + _name = "partner.info" + _inherit = "partner.short.info" + + street = fields.String(required=True, allow_none=False) + street2 = fields.String(required=False, allow_none=True) + zip_code = fields.String(required=True, allow_none=False) + city = fields.String(required=True, allow_none=False) + phone = fields.String(required=False, allow_none=True) + is_componay = fields.Boolean(required=False, allow_none=False) +``` + +As for odoo models, you can extend the base datamodel by inheriting of +base. + +``` python +class Base(Datamodel): + _inherit = "base" + + def _my_method(self): + pass +``` + +Datamodels are available through the datamodels registry provided by the +Odoo's environment. + +``` python +class ResPartner(Model): + _inherit = "res.partner" + + def _to_partner_info(self): + PartnerInfo = self.env.datamodels["partner.info"] + partner_info = PartnerInfo(partial=True) + partner_info.id = partner.id + partner_info.name = partner.name + partner_info.street = partner.street + partner_info.street2 = partner.street2 + partner_info.zip_code = partner.zip + partner_info.city = partner.city + partner_info.phone = partner.phone + partner_info.is_company = partner.is_company + return partner_info +``` + +The Odoo's environment is also available into the datamodel instance. + +``` python +class MyDataModel(Datamodel): + _name = "my.data.model" + + def _my_method(self): + partners = self.env["res.partner"].search([]) +``` + +> [!WARNING] +> The env property into a Datamodel instance is mutable. IOW, you can't +> rely on information (context, user) provided by the environment. The +> env property is a helper property that give you access to the odoo's +> registry and must be use with caution. diff --git a/datamodel/static/description/icon.png b/datamodel/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/datamodel/static/description/icon.png differ diff --git a/datamodel/static/description/index.html b/datamodel/static/description/index.html new file mode 100644 index 000000000..b85067775 --- /dev/null +++ b/datamodel/static/description/index.html @@ -0,0 +1,526 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Datamodel

+ +

Beta License: LGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

+

This addon allows you to define simple data models supporting +serialization/deserialization to/from json

+

Datamodels are Marshmallow +models classes that +can be inherited as Odoo Models.

+

Table of contents

+ +
+

Usage

+

To define your own datamodel you just need to create a class that +inherits from odoo.addons.datamodel.core.Datamodel

+
+from marshmallow import fields
+
+from odoo.addons.base_rest import restapi
+from odoo.addons.component.core import Component
+from odoo.addons.datamodel.core import Datamodel
+
+
+class PartnerShortInfo(Datamodel):
+    _name = "partner.short.info"
+
+    id = fields.Integer(required=True, allow_none=False)
+    name = fields.String(required=True, allow_none=False)
+
+class PartnerInfo(Datamodel):
+    _name = "partner.info"
+    _inherit = "partner.short.info"
+
+    street = fields.String(required=True, allow_none=False)
+    street2 = fields.String(required=False, allow_none=True)
+    zip_code = fields.String(required=True, allow_none=False)
+    city = fields.String(required=True, allow_none=False)
+    phone = fields.String(required=False, allow_none=True)
+    is_componay = fields.Boolean(required=False, allow_none=False)
+
+

As for odoo models, you can extend the base datamodel by inheriting of +base.

+
+class Base(Datamodel):
+    _inherit = "base"
+
+    def _my_method(self):
+        pass
+
+

Datamodels are available through the datamodels registry provided by the +Odoo’s environment.

+
+class ResPartner(Model):
+    _inherit = "res.partner"
+
+    def _to_partner_info(self):
+        PartnerInfo = self.env.datamodels["partner.info"]
+        partner_info = PartnerInfo(partial=True)
+        partner_info.id = partner.id
+        partner_info.name = partner.name
+        partner_info.street = partner.street
+        partner_info.street2 = partner.street2
+        partner_info.zip_code = partner.zip
+        partner_info.city = partner.city
+        partner_info.phone = partner.phone
+        partner_info.is_company = partner.is_company
+        return partner_info
+
+

The Odoo’s environment is also available into the datamodel instance.

+
+class MyDataModel(Datamodel):
+    _name = "my.data.model"
+
+    def _my_method(self):
+        partners = self.env["res.partner"].search([])
+
+
+

Warning

+

The env property into a Datamodel instance is mutable. IOW, you can’t +rely on information (context, user) provided by the environment. The +env property is a helper property that give you access to the odoo’s +registry and must be use with caution.

+
+
+
+

Known issues / Roadmap

+

The +roadmap +and known +issues +can be found on GitHub.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

lmignon

+

This module is part of the OCA/rest-framework project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/datamodel/tests/__init__.py b/datamodel/tests/__init__.py new file mode 100644 index 000000000..e43f8bf6a --- /dev/null +++ b/datamodel/tests/__init__.py @@ -0,0 +1 @@ +from . import test_build_datamodel diff --git a/datamodel/tests/common.py b/datamodel/tests/common.py new file mode 100644 index 000000000..2afe3668b --- /dev/null +++ b/datamodel/tests/common.py @@ -0,0 +1,208 @@ +# Copyright 2017 Camptocamp SA +# Copyright 2019 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +import copy +from contextlib import contextmanager + +import odoo +from odoo import api +from odoo.modules.registry import Registry +from odoo.tests import common + +from ..core import ( + DatamodelRegistry, + MetaDatamodel, + _datamodel_databases, + _get_addon_name, +) + + +@contextmanager +def new_rollbacked_env(): + registry = Registry(common.get_db_name()) + uid = odoo.SUPERUSER_ID + cr = registry.cursor() + try: + yield api.Environment(cr, uid, {}) + finally: + cr.rollback() # we shouldn't have to commit anything + cr.close() + + +class DatamodelMixin: + @classmethod + def setUpDatamodel(cls): + with new_rollbacked_env() as env: + builder = env["datamodel.builder"] + # build the datamodels of every installed addons + datamodel_registry = builder._init_global_registry() + cls._datamodels_registry = datamodel_registry + # ensure that we load only the datamodels of the 'installed' + # modules, not 'to install', which means we load only the + # dependencies of the tested addons, not the siblings or + # chilren addons + builder.build_registry(datamodel_registry, states=("installed",)) + # build the datamodels of the current tested addon + current_addon = _get_addon_name(cls.__module__) + env["datamodel.builder"].load_datamodels(current_addon) + + # pylint: disable=W8106 + def setUp(self): + # should be ready only during tests, never during installation + # of addons + self._datamodels_registry.ready = True + + @self.addCleanup + def notready(): + self._datamodels_registry.ready = False + + +class TransactionDatamodelCase(common.TransactionCase, DatamodelMixin): + """A TransactionCase that loads all the datamodels + + It is used like an usual Odoo's TransactionCase, but it ensures + that all the datamodels of the current addon and its dependencies + are loaded. + + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.setUpDatamodel() + + # pylint: disable=W8106 + def setUp(self): + # resolve an inheritance issue (common.TransactionCase does not call + # super) + common.TransactionCase.setUp(self) + DatamodelMixin.setUp(self) + + +class DatamodelRegistryCase(common.BaseCase): + """This test case can be used as a base for writings tests on datamodels + + This test case is meant to test datamodels in a special datamodel registry, + where you want to have maximum control on which datamodels are loaded + or not, or when you want to create additional datamodels in your tests. + + If you only want to *use* the datamodels of the tested addon in your tests, + then consider using: + + * :class:`TransactionDatamodelCase` + + This test case creates a special + :class:`odoo.addons.datamodel.core.DatamodelRegistry` for the purpose of + the tests. By default, it loads all the datamodels of the dependencies, but + not the datamodels of the current addon (which you have to handle + manually). In your tests, you can add more datamodels in 2 manners. + + All the datamodels of an Odoo module:: + + self._load_module_datamodels('connector') + + Only specific datamodels:: + + self._build_datamodels(MyDatamodel1, MyDatamodel2) + + Note: for the lookups of the datamodels, the default datamodel + registry is a global registry for the database. Here, you will + need to explicitly pass ``self.datamodel_registry`` in the + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + # keep the original classes registered by the metaclass + # so we'll restore them at the end of the tests, it avoid + # to pollute it with Stub / Test datamodels + cls._original_datamodels = copy.deepcopy(MetaDatamodel._modules_datamodels) + + # it will be our temporary datamodel registry for our test session + cls.datamodel_registry = DatamodelRegistry() + + # it builds the 'final datamodel' for every datamodel of the + # 'datamodel' addon and push them in the datamodel registry + cls.datamodel_registry.load_datamodels("datamodel") + # build the datamodels of every installed addons already installed + # but the current addon (when running with pytest/nosetest, we + # simulate the --test-enable behavior by excluding the current addon + # which is in 'to install' / 'to upgrade' with --test-enable). + current_addon = _get_addon_name(cls.__module__) + + registry = Registry(common.get_db_name()) + cr = registry.cursor() + uid = odoo.SUPERUSER_ID + env = api.Environment(cr, uid, {}) + env["datamodel.builder"].build_registry( + cls.datamodel_registry, + states=("installed",), + exclude_addons=[current_addon], + ) + cls.env = env + _datamodel_databases[cls.env.cr.dbname] = cls.datamodel_registry + + def _close_and_rollback(): + cr.rollback() # we shouldn't have to commit anything + cr.close() + + cls.addClassCleanup(_close_and_rollback) + + # Fake that we are ready to work with the registry + # normally, it is set to True and the end of the build + # of the datamodels. Here, we'll add datamodels later in + # the datamodels registry, but we don't mind for the tests. + cls.datamodel_registry.ready = True + cls._original_registry_datamodels = copy.deepcopy( + cls.datamodel_registry._datamodels + ) + + def tearDown(self): + super().tearDown() + # restore the original metaclass' classes + MetaDatamodel._modules_datamodels = self._original_datamodels + self.datamodel_registry._datamodels = copy.deepcopy( + self._original_registry_datamodels + ) + + def _load_module_datamodels(self, module): + self.datamodel_registry.load_datamodels(module) + + def _build_datamodels(self, *classes): + for cls in classes: + cls._build_datamodel(self.datamodel_registry) + + +class TransactionDatamodelRegistryCase(common.TransactionCase, DatamodelRegistryCase): + """Adds Odoo Transaction in the base Datamodel TestCase""" + + # pylint: disable=W8106 + @classmethod + def setUpClass(cls): + # resolve an inheritance issue (common.TransactionCase does not use + # super) + common.TransactionCase.setUpClass(cls) + DatamodelRegistryCase.setUp(cls) + cls.collection = cls.env["collection.base"] + + def teardown(self): + common.TransactionCase.tearDown(self) + DatamodelRegistryCase.tearDown(self) + + +class SavepointDatamodelRegistryCase(common.TransactionCase, DatamodelRegistryCase): + """Adds Odoo Transaction with Savepoint in the base Datamodel TestCase""" + + @classmethod + def setUpClass(cls): + # resolve an inheritance issue (common.SavepointCase does not use + # super) + super().setUpClass() + cls.collection = cls.env["collection.base"] + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + common.TransactionCase.tearDownClass(cls) + DatamodelRegistryCase.tearDown(cls) diff --git a/datamodel/tests/test_build_datamodel.py b/datamodel/tests/test_build_datamodel.py new file mode 100644 index 000000000..ddc431e8b --- /dev/null +++ b/datamodel/tests/test_build_datamodel.py @@ -0,0 +1,485 @@ +# Copyright 2017 Camptocamp SA +# Copyright 2019 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +from unittest import mock + +from marshmallow_objects.models import Model as MarshmallowModel + +from odoo import SUPERUSER_ID, api + +from .. import fields +from ..core import Datamodel +from .common import DatamodelRegistryCase, TransactionDatamodelCase + + +class TestBuildDatamodel(DatamodelRegistryCase): + """Test build of datamodels + + All the tests in this suite are based on the same principle with + variations: + + * Create new Datamodels (classes inheriting from + :class:`datamodel.core.Datamodel` + * Call :meth:`datamodel.core.Datamodel._build_datamodel` on them + in order to build the 'final class' composed from all the ``_inherit`` + and push it in the datamodels registry (``self.datamodel_registry`` here) + * Assert that classes are built, registered, have correct ``__bases__``... + + """ + + def test_type(self): + """Ensure that a datamodels are instances of + marshomallow_objects.Model""" + + class Datamodel1(Datamodel): + _name = "datamodel1" + + class Datamodel2(Datamodel): + _name = "datamodel2" + _inherit = "datamodel1" + + self.assertIsInstance(Datamodel1(), MarshmallowModel) + self.assertIsInstance(Datamodel2(), MarshmallowModel) + + def test_no_name(self): + """Ensure that a datamodel has a _name""" + + class Datamodel1(Datamodel): + pass + + msg = ".*must have a _name.*" + with self.assertRaisesRegex(TypeError, msg): + Datamodel1._build_datamodel(self.datamodel_registry) + + def test_register(self): + """Able to register datamodels in datamodels registry""" + + class Datamodel1(Datamodel): + _name = "datamodel1" + + class Datamodel2(Datamodel): + _name = "datamodel2" + + # build the 'final classes' for the datamodels and check that we find + # them in the datamodels registry + Datamodel1._build_datamodel(self.datamodel_registry) + Datamodel2._build_datamodel(self.datamodel_registry) + self.assertEqual( + ["base", "datamodel1", "datamodel2"], list(self.datamodel_registry) + ) + + def test_inherit_bases(self): + """Check __bases__ of Datamodel with _inherit""" + + class Datamodel1(Datamodel): + _name = "datamodel1" + field_str1 = fields.String(load_default="field_str1") + + class Datamodel2(Datamodel): + _inherit = "datamodel1" # pylint:disable=R8180 + field_str2 = fields.String(load_default="field_str2") + + class Datamodel3(Datamodel): + _inherit = "datamodel1" # pylint:disable=R8180 + field_str3 = fields.String(load_default="field_str3") + + Datamodel1._build_datamodel(self.datamodel_registry) + Datamodel2._build_datamodel(self.datamodel_registry) + Datamodel3._build_datamodel(self.datamodel_registry) + self.assertEqual( + (Datamodel3, Datamodel2, Datamodel1, self.env.datamodels["base"]), + self.env.datamodels["datamodel1"].__bases__, + ) + + def test_prototype_inherit_bases(self): + """Check __bases__ of Datamodel with _inherit and different _name""" + + class Datamodel1(Datamodel): + _name = "datamodel1" + field_int = fields.Int(load_default=1) + + class Datamodel2(Datamodel): + _name = "datamodel2" + _inherit = "datamodel1" + field_boolean = fields.Boolean(load_default=True) + field_int = fields.Int(load_default=2) + + class Datamodel3(Datamodel): + _name = "datamodel3" + _inherit = "datamodel1" + field_float = fields.Float(load_default=0.3) + + class Datamodel4(Datamodel): + _name = "datamodel4" + _inherit = ["datamodel2", "datamodel3"] + + Datamodel1._build_datamodel(self.datamodel_registry) + Datamodel2._build_datamodel(self.datamodel_registry) + Datamodel3._build_datamodel(self.datamodel_registry) + Datamodel4._build_datamodel(self.datamodel_registry) + self.assertEqual( + (Datamodel1, self.env.datamodels["base"]), + self.env.datamodels["datamodel1"].__bases__, + ) + self.assertEqual( + ( + Datamodel2, + self.env.datamodels["datamodel1"], + self.env.datamodels["base"], + ), + self.env.datamodels["datamodel2"].__bases__, + ) + self.assertEqual( + ( + Datamodel3, + self.env.datamodels["datamodel1"], + self.env.datamodels["base"], + ), + self.env.datamodels["datamodel3"].__bases__, + ) + self.assertEqual( + ( + Datamodel4, + self.env.datamodels["datamodel2"], + self.env.datamodels["datamodel3"], + self.env.datamodels["base"], + ), + self.env.datamodels["datamodel4"].__bases__, + ) + + def test_final_class_schema(self): + """Check the MArshmallow schema of the final class""" + + class Datamodel1(Datamodel): + _name = "datamodel1" + field_int = fields.Int(load_default=1) + + class Datamodel2(Datamodel): + _name = "datamodel2" + _inherit = "datamodel1" + field_boolean = fields.Boolean(load_default=True) + field_int = fields.Int(load_default=2) + + class Datamodel3(Datamodel): + _name = "datamodel3" + _inherit = "datamodel1" + field_float = fields.Float(load_default=0.3) + + class Datamodel4(Datamodel): + _name = "datamodel4" + _inherit = ["datamodel2", "datamodel3"] + + Datamodel1._build_datamodel(self.datamodel_registry) + Datamodel2._build_datamodel(self.datamodel_registry) + Datamodel3._build_datamodel(self.datamodel_registry) + Datamodel4._build_datamodel(self.datamodel_registry) + + Datamodel1 = self.env.datamodels["datamodel1"] + Datamodel2 = self.env.datamodels["datamodel2"] + Datamodel3 = self.env.datamodels["datamodel3"] + Datamodel4 = self.env.datamodels["datamodel4"] + + self.assertEqual(Datamodel1().dump(), {"field_int": 1}) + self.assertDictEqual( + Datamodel2().dump(), {"field_boolean": True, "field_int": 2} + ) + self.assertDictEqual(Datamodel3().dump(), {"field_float": 0.3, "field_int": 1}) + self.assertDictEqual( + Datamodel4().dump(), + {"field_boolean": True, "field_int": 2, "field_float": 0.3}, + ) + + def test_custom_build(self): + """Check that we can hook at the end of a Datamodel build""" + + class Datamodel1(Datamodel): + _name = "datamodel1" + + @classmethod + def _complete_datamodel_build(cls): + # This method should be called after the Datamodel + # is built, and before it is pushed in the registry + cls._build_done = True + + Datamodel1._build_datamodel(self.datamodel_registry) + # we inspect that our custom build has been executed + self.assertTrue(self.env.datamodels["datamodel1"]._build_done) + + # pylint: disable=W8110 + def test_inherit_attrs(self): + """Check attributes inheritance of Datamodels with _inherit""" + + class Datamodel1(Datamodel): + _name = "datamodel1" + + msg = "ping" + + def say(self): + return "foo" + + class Datamodel2(Datamodel): + _name = "datamodel2" + _inherit = "datamodel1" + + msg = "pong" + + def say(self): + return super().say() + " bar" + + Datamodel1._build_datamodel(self.datamodel_registry) + Datamodel2._build_datamodel(self.datamodel_registry) + # we initialize the datamodels, normally we should pass + # an instance of WorkContext, but we don't need a real one + # for this test + datamodel1 = self.env.datamodels["datamodel1"](mock.Mock()) + datamodel2 = self.env.datamodels["datamodel2"](mock.Mock()) + self.assertEqual("ping", datamodel1.msg) + self.assertEqual("pong", datamodel2.msg) + self.assertEqual("foo", datamodel1.say()) + self.assertEqual("foo bar", datamodel2.say()) + + def test_duplicate_datamodel(self): + """Check that we can't have 2 datamodels with the same name""" + + class Datamodel1(Datamodel): + _name = "datamodel1" + + class Datamodel2(Datamodel): + _name = "datamodel1" + + Datamodel1._build_datamodel(self.datamodel_registry) + msg = "Datamodel.*already exists.*" + with self.assertRaisesRegex(TypeError, msg): + Datamodel2._build_datamodel(self.datamodel_registry) + + def test_no_parent(self): + """Ensure we can't _inherit a non-existent datamodel""" + + class Datamodel1(Datamodel): + _name = "datamodel1" + _inherit = "datamodel1" + + msg = "Datamodel.*does not exist in registry.*" + with self.assertRaisesRegex(TypeError, msg): + Datamodel1._build_datamodel(self.datamodel_registry) + + def test_no_parent2(self): + """Ensure we can't _inherit by prototype a non-existent datamodel""" + + class Datamodel1(Datamodel): + _name = "datamodel1" + + class Datamodel2(Datamodel): + _name = "datamodel2" + _inherit = ["datamodel1", "datamodel3"] + + Datamodel1._build_datamodel(self.datamodel_registry) + msg = "Datamodel.*inherits from non-existing datamodel.*" + with self.assertRaisesRegex(TypeError, msg): + Datamodel2._build_datamodel(self.datamodel_registry) + + def test_add_inheritance(self): + """Ensure we can add a new inheritance""" + + class Datamodel1(Datamodel): + _name = "datamodel1" + + class Datamodel2(Datamodel): + _name = "datamodel2" + + class Datamodel2bis(Datamodel): + _name = "datamodel2" + _inherit = ["datamodel2", "datamodel1"] + + Datamodel1._build_datamodel(self.datamodel_registry) + Datamodel2._build_datamodel(self.datamodel_registry) + Datamodel2bis._build_datamodel(self.datamodel_registry) + + self.assertEqual( + ( + Datamodel2bis, + Datamodel2, + self.env.datamodels["datamodel1"], + self.env.datamodels.registry.get("base"), + ), + self.env.datamodels["datamodel2"].__bases__, + ) + + def test_add_inheritance_final_schema(self): + """Ensure that the Marshmallow schema is updated if we add a + new inheritance""" + + class Datamodel1(Datamodel): + _name = "datamodel1" + field_str1 = fields.String(load_default="str1") + + class Datamodel2(Datamodel): + _name = "datamodel2" + field_str2 = fields.String(load_default="str2") + + class Datamodel2bis(Datamodel): + _name = "datamodel2" + _inherit = ["datamodel2", "datamodel1"] + field_str3 = fields.String(load_default="str3") + + Datamodel1._build_datamodel(self.datamodel_registry) + Datamodel2._build_datamodel(self.datamodel_registry) + Datamodel2bis._build_datamodel(self.datamodel_registry) + + Datamodel2 = self.env.datamodels["datamodel2"] + self.assertDictEqual( + Datamodel2().dump(), + {"field_str1": "str1", "field_str2": "str2", "field_str3": "str3"}, + ) + + def test_recursion(self): + class Datamodel1(Datamodel): + _name = "datamodel1" + field_str = fields.String() + + Datamodel1._build_datamodel(self.datamodel_registry) + for _i in range(0, 1000): + self.env.datamodels["datamodel1"](field_str="1234") + + def test_nested_model(self): + """Test nested model serialization/deserialization""" + + class Parent(Datamodel): + _name = "parent" + name = fields.String() + child = fields.NestedModel("child") + + class Child(Datamodel): + _name = "child" + field_str = fields.String() + + Parent._build_datamodel(self.datamodel_registry) + Child._build_datamodel(self.datamodel_registry) + + Parent = self.env.datamodels["parent"] + Child = self.env.datamodels["child"] + + instance = Parent(name="Parent", child=Child(field_str="My other string")) + res = instance.dump() + self.assertDictEqual( + res, {"child": {"field_str": "My other string"}, "name": "Parent"} + ) + new_instance = instance.load(res) + self.assertEqual(new_instance.name, instance.name) + self.assertEqual(new_instance.child.field_str, instance.child.field_str) + + def test_list_nested_model(self): + """Test list model of nested model serialization/deserialization""" + + class Parent(Datamodel): + _name = "parent" + name = fields.String() + list_child = fields.List(fields.NestedModel("child")) + + class Child(Datamodel): + _name = "child" + field_str = fields.String() + + Parent._build_datamodel(self.datamodel_registry) + Child._build_datamodel(self.datamodel_registry) + + Parent = self.env.datamodels["parent"] + Child = self.env.datamodels["child"] + + childs = [ + Child(field_str="My 1st other string"), + Child(field_str="My 2nd other string"), + ] + instance = Parent(name="Parent", list_child=childs) + res = instance.dump() + self.assertDictEqual( + res, + { + "list_child": [ + {"field_str": "My 1st other string"}, + {"field_str": "My 2nd other string"}, + ], + "name": "Parent", + }, + ) + new_instance = instance.load(res) + self.assertEqual(new_instance.name, instance.name) + self.assertEqual(new_instance.list_child, instance.list_child) + + def test_many(self): + """Test loads of many""" + + class Item(Datamodel): + _name = "item" + idx = fields.Integer() + + Item._build_datamodel(self.datamodel_registry) + Item = self.env.datamodels["item"] + + items = Item.load([{"idx": 1}, {"idx": 2}], many=True) + self.assertTrue(len(items), 2) + self.assertEqual([i.idx for i in items], [1, 2]) + + def test_nested_many(self): + """Tests loads and dump of model with array of nested model""" + + class Parent(Datamodel): + _name = "parent" + items = fields.NestedModel("item", many=True) + + class Item(Datamodel): + _name = "item" + idx = fields.Integer() + + Parent._build_datamodel(self.datamodel_registry) + Item._build_datamodel(self.datamodel_registry) + + Parent = self.env.datamodels["parent"] + Item = self.env.datamodels["item"] + + instance = Parent.load({"items": [{"idx": 1}, {"idx": 2}]}) + res = instance.dump() + self.assertEqual(res, {"items": [{"idx": 1}, {"idx": 2}]}) + new_instance = Parent.load(res) + self.assertEqual(len(new_instance.items), 2) + self.assertEqual([i.idx for i in new_instance.items], [1, 2]) + new_instance.items.append(Item(idx=3)) + res = new_instance.dump() + self.assertEqual(res, {"items": [{"idx": 1}, {"idx": 2}, {"idx": 3}]}) + + def test_env(self): + """ + Tests that the current env is always available on datamodel instances + and schema + """ + + class Parent(Datamodel): + _name = "parent" + items = fields.NestedModel("item", many=True) + + class Item(Datamodel): + _name = "item" + idx = fields.Integer() + + Parent._build_datamodel(self.datamodel_registry) + Item._build_datamodel(self.datamodel_registry) + Parent = self.env.datamodels["parent"] + p = Parent() + self.assertEqual(p.env, self.env) + schema = Parent.get_schema() + self.assertEqual(schema._env, self.env) + instance = Parent.load({"items": [{"idx": 1}, {"idx": 2}]}) + self.assertEqual(instance.items[0].env, self.env) + schema = instance.items[0].get_schema() + self.assertEqual(schema._env, self.env) + another_env = api.Environment(self.env.registry.cursor(), SUPERUSER_ID, {}) + new_p = another_env.datamodels["parent"]() + self.assertEqual(new_p.env, another_env) + + +class TestRegistryAccess(TransactionDatamodelCase): + def test_registry_access(self): + """Check the access to the registry directly on tnv""" + base = self.env.datamodels["base"] + self.assertIsInstance(base(), MarshmallowModel) diff --git a/requirements.txt b/requirements.txt index 69f99aad8..bae1b322b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,3 @@ # generated from manifests external_dependencies -a2wsgi>=1.10.6 -apispec -cerberus -contextvars -extendable>=0.0.4 -fastapi>=0.110.0 -parse-accept-language -pydantic>=2.0.0 -pyquerystring -python-multipart -typing-extensions -ujson +marshmallow-objects>=2.0.0 +marshmallow<4.0.0