diff --git a/__init__.py b/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/model_serializer/README.rst b/model_serializer/README.rst new file mode 100644 index 000000000..880ecb6fa --- /dev/null +++ b/model_serializer/README.rst @@ -0,0 +1,188 @@ +================ +Model Serializer +================ + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-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/13.0/model_serializer + :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-13-0/rest-framework-13-0-model_serializer + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/271/13.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module takes advantage of the concepts introduced in the `datamodel` module to offer mechanisms similar to +(a subset of) the `ModelSerializer` in Django REST Framework. That is, use the definition of the Odoo model to +partially automate the definition of a corresponding `Datamodel` class. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +:code:`ModelSerializer` class +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :code:`ModelSerializer` class inherits from the :code:`Datamodel` class and adds functionalities. Therefore any +class inheriting from :code:`ModelSerializer` can be used the exact same way as any other :code:`Datamodel`. + +Basic usage +*********** + +Here is a basic example:: + + from odoo.addons.model_serializer.core import ModelSerializer + + class PartnerInfo(ModelSerializer): + _name = "partner.info" + _model = "res.partner" + _model_fields = ["id", "name", "country_id"] + +The result is equivalent to the following :code:`Datamodel` classes:: + + from marshmallow import fields + + from odoo.addons.datamodel.core import Datamodel + from odoo.addons.datamodel.fields import NestedModel + + + class PartnerInfo(Datamodel): + _name = "partner.info" + + id = fields.Integer(required=True, allow_none=False, dump_only=True) + name = fields.String(required=True, allow_none=False) + country = NestedModel("_auto_nested_serializer.res.country") + + + class _AutoNestedSerializerResCountry(Datamodel): + _name = "_auto_nested_serializer.res.country" + + id = fields.Integer(required=True, allow_none=False, dump_only=True) + display_name = fields.String(dump_only=True) + + +Overriding fields definition +**************************** + +It is possible to override the default definition of fields as such:: + + from odoo.addons.model_serializer.core import ModelSerializer + + class PartnerInfo(ModelSerializer): + _name = "partner.info" + _model = "res.partner" + _model_fields = ["id", "name", "country_id"] + + country_id = NestedModel("country.info") + + class CountryInfo(ModelSerializer): + _name = "country.info" + _model = "res.country" + _model_fields = ["code", "name"] + +In this example, we override a :code:`NestedModel` but it works the same for any other field type. + +(De)serialization +***************** + +:code:`ModelSerializer` does all the heavy-lifting of transforming a :code:`Datamodel` instance into the corresponding +:code:`recordset`, and vice-versa. + +To transform a recordset into a (list of) :code:`ModelSerializer` instance(s) (serialization), do the following:: + + partner_info = self.env.datamodels["partner.info"].from_recordset(partner) + +This will return a single instance; if your recordset contains more than one record, you can get a list of instances +by passing :code:`many=True` to this method. + + +To transform a :code:`ModelSerializer` instance into a recordset (de-serialization), do the following:: + + partner = partner_info.to_recordset() + +Unless an existing partner can be found (see below), this method **creates a new record** in the database. You can avoid +that by passing :code:`create=False`, in which case the system will only create them in memory (:code:`NewId` recordset). + +In order to determine if the corresponding Odoo record already exists or if a new one should be created, the system +checks by default if the :code:`id` field of the instance corresponds to a database record. This default behavior can be +modified like so:: + + class CountryInfo(ModelSerializer): + _name = "country.info" + _model = "res.country" + _model_fields = ["code", "name"] + + def get_odoo_record(self): + if self.code: + return self.env[self._model].search([("code", "=", self.code)]) + return super().get_odoo_record() + +Changelog +========= + +13.0.1.0.0 +~~~~~~~~~~ + +First official version. + +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 smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Wakari + +Contributors +~~~~~~~~~~~~ + +* François Degrave + +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. + +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/model_serializer/__init__.py b/model_serializer/__init__.py new file mode 100644 index 000000000..a42838e17 --- /dev/null +++ b/model_serializer/__init__.py @@ -0,0 +1,4 @@ +from . import builder +from . import core +from . import field_converter +from . import serializers diff --git a/model_serializer/__manifest__.py b/model_serializer/__manifest__.py new file mode 100644 index 000000000..d332bb95f --- /dev/null +++ b/model_serializer/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2021 Wakari SRL (http://www.wakari.be) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +{ + "name": "Model Serializer", + "summary": "Automatically translate Odoo models into Datamodels " + "for (de)serialization", + "version": "13.0.1.0.0", + "development_status": "Alpha", + "license": "LGPL-3", + "website": "https://github.com/OCA/rest-framework", + "author": "Wakari, Odoo Community Association (OCA)", + "depends": ["datamodel"], + "data": [], +} diff --git a/model_serializer/builder.py b/model_serializer/builder.py new file mode 100644 index 000000000..ad97532f8 --- /dev/null +++ b/model_serializer/builder.py @@ -0,0 +1,23 @@ +from odoo import models + +from odoo.addons.datamodel.core import MetaDatamodel, _datamodel_databases + +from .core import ModelSerializer + + +class DatamodelBuilder(models.AbstractModel): + _inherit = "datamodel.builder" + + def load_datamodels(self, module, datamodels_registry=None): + super().load_datamodels(module, datamodels_registry=datamodels_registry) + datamodels_registry = ( + datamodels_registry or _datamodel_databases[self.env.cr.dbname] + ) + for datamodel_class in MetaDatamodel._modules_datamodels[module]: + self._extend_model_serializer(datamodel_class, datamodels_registry) + + def _extend_model_serializer(self, datamodel_class, registry): + """Extend the datamodel_class with the fields declared in `_model_fields`""" + if issubclass(datamodel_class, ModelSerializer): + new_class = datamodel_class._extend_from_odoo_model(registry, self.env) + new_class._build_datamodel(registry) diff --git a/model_serializer/core.py b/model_serializer/core.py new file mode 100644 index 000000000..4f0bc9364 --- /dev/null +++ b/model_serializer/core.py @@ -0,0 +1,223 @@ +from odoo import _, models +from odoo.exceptions import ValidationError + +from odoo.addons.datamodel.core import Datamodel, MetaDatamodel + +from .field_converter import convert_field + + +class class_or_instancemethod(classmethod): # pylint: disable=class-camelcase + def __get__(self, instance, type_): + descr_get = super().__get__ if instance is None else self.__func__.__get__ + return descr_get(instance, type_) + + +class MetaModelSerializer(MetaDatamodel): + def __init__(self, name, bases, attrs): + super(MetaModelSerializer, self).__init__(name, bases, attrs) + + +class ModelSerializer(Datamodel, metaclass=MetaModelSerializer): + _inherit = "base" + _register = False + _model = None + _model_fields = [] + + def dump(self, many=None): + with self.__dump_mode_on__(): + dump = self.__schema__.dump(self, many=many) + return dump + + @classmethod + def _check_nested_class(cls, marshmallow_field, registry): + """If `marshmallow_field` is a nested datamodel (relational field), we check + if the nested datamodel class exists + """ + nested_name = getattr(marshmallow_field, "datamodel_name", None) + if nested_name and nested_name not in registry: + raise ValidationError( + _("'{}' datamodel does not exist").format(nested_name) + ) + + @classmethod + def _extend_from_odoo_model(cls, registry, env): + """Extend the datamodel to contain the declared Odoo model fields""" + attrs = { + "_inherit": cls._name, + "_model_fields": getattr(cls, "_model_fields", []), + "_model": getattr(cls, "_model", None), + } + bases = (ModelSerializer,) + name = cls.__name__ + "Child" + parent_class = registry[cls._name] + has_model_fields = bool(attrs["_model_fields"]) + if getattr(parent_class, "_model_fields", None): + has_model_fields = True + if getattr(parent_class, "_model", None): + if attrs["_model"] and attrs["_model"] != parent_class._model: + raise ValidationError( + _( + "Error in {}: Model Serializers cannot inherit " + "from a class having a different '_model' attribute" + ).format(cls.__name__) + ) + attrs["_model"] = parent_class._model + + if not (attrs["_model"] and has_model_fields): + raise ValidationError( + _( + "Error in {}: Model Serializers require '_model' and " + "'_model_fields' attributes to be defined" + ).format(cls.__name__) + ) + + odoo_model = env[attrs["_model"]] + for field_name in cls._model_fields: + if not hasattr(cls, field_name): + odoo_field = odoo_model._fields[field_name] + marshmallow_field = convert_field(odoo_field) + if marshmallow_field: + attrs[field_name] = marshmallow_field + else: + marshmallow_field = cls.__schema_class__._declared_fields[field_name] + cls._check_nested_class(marshmallow_field, registry) + return MetaDatamodel(name, bases, attrs) + + @property + def _model_name(self): + if self.context.get("odoo_model"): + return self.context["odoo_model"] + return self._model + + @_model_name.setter + def _model_name(self, value): + if not self.context: + self.context = {} + self.context["odoo_model"] = value + + @classmethod + def from_recordset(cls, recordset, *, many=False): + """Transform a recordset into a (list of) datamodel(s)""" + + def convert_null_value(val): + if val: + return val + if val is False or isinstance(val, models.BaseModel): + return None + return val + + res = [] + datamodels = recordset.env.datamodels + recordset = recordset if many else recordset[:1] + for record in recordset: + instance = cls(partial=True, context={"odoo_model": record._name}) + for model_field in cls._model_fields: + schema_field = instance.__schema__.fields[model_field] + nested_datamodel_name = getattr(schema_field, "datamodel_name", None) + if nested_datamodel_name: + nested_datamodel_class = datamodels[nested_datamodel_name] + if hasattr(nested_datamodel_class, "from_recordset"): + setattr( + instance, + model_field, + nested_datamodel_class.from_recordset( + record[model_field], many=schema_field.many + ), + ) + else: + value = convert_null_value(record[model_field]) + setattr(instance, model_field, value) + res.append(instance) + if not many: + return res[0] if res else None + return res + + def get_odoo_record(self): + """Get an existing record matching `self`. Meant to be overridden + TODO: optimize this to deal with multiple instances at once + """ + odoo_model = self.env[self._model_name] + if "id" in self._model_fields and getattr(self, "id", None): + return odoo_model.browse(self.id) + return odoo_model.browse([]) + + def _new_odoo_record(self): + odoo_model = self.env[self._model_name] + default_values = odoo_model.default_get(odoo_model._fields.keys()) + return odoo_model.new(default_values) + + def _process_model_value(self, value, model_field): + if hasattr(self, "validate_{}".format(model_field)): + return getattr(self, "validate_{}".format(model_field))(value) + return value + + def _get_partial_fields(self): + """Return the list of fields actually used to instantiate `self`""" + res = [] + received_keys = self.dump(many=False).keys() + actual_field_names = { + field.data_key: name + for name, field in self.__schema__._declared_fields.items() + if field.data_key + } + for received_key in received_keys: + res.append(actual_field_names.get(received_key) or received_key) + return res + + def convert_to_values(self, model=None): + """Transform `self` into a dictionary to create or write an odoo record""" + + def convert_related_values(dics): + res = [(6, 0, [])] + for dic in dics: + rec_id = dic.pop("id", None) + if rec_id: + res[0][2].append(rec_id) + if dic: + res.append((1, rec_id, dic)) + else: + res.append((4, rec_id)) + res.append((0, 0, dic)) + return res + + model_name = model or self._model + self._model_name = model_name + record = self.get_odoo_record() + values = {"id": record.id} if record else {} + # in case of partial, not all fields are considered + received_fields = self._get_partial_fields() + model_fields = set(received_fields) & set(self._model_fields) + for model_field in model_fields: + schema_field = self.__schema__.fields[model_field] + if schema_field.dump_only: + continue + value = getattr(self, model_field) + nested_datamodel_name = getattr(schema_field, "datamodel_name", None) + nested_datamodel = ( + self.env.datamodels[nested_datamodel_name] + if nested_datamodel_name + else None + ) + if nested_datamodel and issubclass(nested_datamodel, ModelSerializer): + odoo_field = record._fields[model_field] + if odoo_field.type == "many2one": + value._model_name = odoo_field.comodel_name + value = value.to_recordset().id + else: + nested_values = [ + instance.convert_to_values(model=odoo_field.comodel_name) + for instance in value + ] + value = convert_related_values(nested_values) + values[model_field] = self._process_model_value(value, model_field) + return values + + def to_recordset(self): + """Transform `self` into a corresponding recordset""" + record = self.get_odoo_record() + values = self.convert_to_values(model=self._model_name) + if record: + record.write(values) + return record + else: + return self.env[self._model_name].create(values) diff --git a/model_serializer/field_converter.py b/model_serializer/field_converter.py new file mode 100644 index 000000000..1dccae1b4 --- /dev/null +++ b/model_serializer/field_converter.py @@ -0,0 +1,131 @@ +import logging + +from marshmallow import fields + +from odoo import fields as odoo_fields + +from odoo.addons.datamodel.fields import NestedModel + +_logger = logging.getLogger(__name__) + +__all__ = ["convert_field"] + + +class Binary(fields.Raw): + def _serialize(self, value, attr, obj, **kwargs): + res = super()._serialize(value, attr, obj, **kwargs) + if isinstance(res, bytes): + res = res.decode("utf-8") + return res + + +class FieldConverter: + def __init__(self, odoo_field): + self.odoo_field = odoo_field + + def _marshmallow_field_class(self): + pass + + def _get_kwargs(self): + kwargs = { + "required": self.odoo_field.required, + "allow_none": not self.odoo_field.required, + } + if self.odoo_field.readonly: + kwargs["dump_only"] = True + return kwargs + + def convert_to_marshmallow(self): + marshmallow_field_class = self._marshmallow_field_class() + kwargs = self._get_kwargs() + return marshmallow_field_class(**kwargs) + + +class BooleanConverter(FieldConverter): + def _get_kwargs(self): + kwargs = super()._get_kwargs() + kwargs["falsy"] = fields.Boolean.falsy.union({None}) + return kwargs + + def _marshmallow_field_class(self): + return fields.Boolean + + +class IntegerConverter(FieldConverter): + def _marshmallow_field_class(self): + return fields.Integer + + +class FloatConverter(FieldConverter): + def _marshmallow_field_class(self): + return fields.Float + + +class StringConverter(FieldConverter): + def _marshmallow_field_class(self): + return fields.String + + +class DateConverter(FieldConverter): + def _marshmallow_field_class(self): + return fields.Date + + +class DatetimeConverter(FieldConverter): + def _marshmallow_field_class(self): + return fields.DateTime + + +class RawConverter(FieldConverter): + def _marshmallow_field_class(self): + return fields.Raw + + +class BinaryConverter(FieldConverter): + def _marshmallow_field_class(self): + return Binary + + +class RelationalConverter(FieldConverter): + def _get_kwargs(self): + kwargs = super()._get_kwargs() + kwargs["many"] = isinstance( + self.odoo_field, (odoo_fields.One2many, odoo_fields.Many2many) + ) + kwargs["nested"] = "generic.minimal.serializer" + kwargs["metadata"] = {"odoo_model": self.odoo_field.comodel_name} + return kwargs + + def _marshmallow_field_class(self): + return NestedModel + + +FIELDS_CONV = { + odoo_fields.Boolean: BooleanConverter, + odoo_fields.Integer: IntegerConverter, + odoo_fields.Id: IntegerConverter, + odoo_fields.Float: FloatConverter, + odoo_fields.Monetary: FloatConverter, # should we use a Decimal instead? + odoo_fields.Char: StringConverter, + odoo_fields.Text: StringConverter, + odoo_fields.Html: StringConverter, + odoo_fields.Selection: RawConverter, + odoo_fields.Date: DateConverter, + odoo_fields.Datetime: DatetimeConverter, + odoo_fields.Binary: BinaryConverter, + odoo_fields.Image: BinaryConverter, + odoo_fields.One2many: RelationalConverter, + odoo_fields.Many2one: RelationalConverter, + odoo_fields.Many2many: RelationalConverter, +} + + +def convert_field(odoo_field): + field_cls = type(odoo_field) + if field_cls in FIELDS_CONV: + return FIELDS_CONV[field_cls](odoo_field).convert_to_marshmallow() + else: + _logger.warning( + "Not implemented: Odoo fields of type {} cannot be " + "translated into Marshmallow fields".format(field_cls) + ) diff --git a/model_serializer/readme/CONTRIBUTORS.rst b/model_serializer/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..88c9e968f --- /dev/null +++ b/model_serializer/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* François Degrave diff --git a/model_serializer/readme/DESCRIPTION.rst b/model_serializer/readme/DESCRIPTION.rst new file mode 100644 index 000000000..7be1895f9 --- /dev/null +++ b/model_serializer/readme/DESCRIPTION.rst @@ -0,0 +1,3 @@ +This module takes advantage of the concepts introduced in the `datamodel` module to offer mechanisms similar to +(a subset of) the `ModelSerializer` in Django REST Framework. That is, use the definition of the Odoo model to +partially automate the definition of a corresponding `Datamodel` class. diff --git a/model_serializer/readme/HISTORY.rst b/model_serializer/readme/HISTORY.rst new file mode 100644 index 000000000..dc27ee260 --- /dev/null +++ b/model_serializer/readme/HISTORY.rst @@ -0,0 +1,4 @@ +13.0.1.0.0 +~~~~~~~~~~ + +First official version. diff --git a/model_serializer/readme/USAGE.rst b/model_serializer/readme/USAGE.rst new file mode 100644 index 000000000..47917ef4b --- /dev/null +++ b/model_serializer/readme/USAGE.rst @@ -0,0 +1,96 @@ +:code:`ModelSerializer` class +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :code:`ModelSerializer` class inherits from the :code:`Datamodel` class and adds functionalities. Therefore any +class inheriting from :code:`ModelSerializer` can be used the exact same way as any other :code:`Datamodel`. + +Basic usage +*********** + +Here is a basic example:: + + from odoo.addons.model_serializer.core import ModelSerializer + + class PartnerInfo(ModelSerializer): + _name = "partner.info" + _model = "res.partner" + _model_fields = ["id", "name", "country_id"] + +The result is equivalent to the following :code:`Datamodel` classes:: + + from marshmallow import fields + + from odoo.addons.datamodel.core import Datamodel + from odoo.addons.datamodel.fields import NestedModel + + + class PartnerInfo(Datamodel): + _name = "partner.info" + + id = fields.Integer(required=True, allow_none=False, dump_only=True) + name = fields.String(required=True, allow_none=False) + country = NestedModel("_auto_nested_serializer.res.country") + + + class _AutoNestedSerializerResCountry(Datamodel): + _name = "_auto_nested_serializer.res.country" + + id = fields.Integer(required=True, allow_none=False, dump_only=True) + display_name = fields.String(dump_only=True) + + +Overriding fields definition +**************************** + +It is possible to override the default definition of fields as such:: + + from odoo.addons.model_serializer.core import ModelSerializer + + class PartnerInfo(ModelSerializer): + _name = "partner.info" + _model = "res.partner" + _model_fields = ["id", "name", "country_id"] + + country_id = NestedModel("country.info") + + class CountryInfo(ModelSerializer): + _name = "country.info" + _model = "res.country" + _model_fields = ["code", "name"] + +In this example, we override a :code:`NestedModel` but it works the same for any other field type. + +(De)serialization +***************** + +:code:`ModelSerializer` does all the heavy-lifting of transforming a :code:`Datamodel` instance into the corresponding +:code:`recordset`, and vice-versa. + +To transform a recordset into a (list of) :code:`ModelSerializer` instance(s) (serialization), do the following:: + + partner_info = self.env.datamodels["partner.info"].from_recordset(partner) + +This will return a single instance; if your recordset contains more than one record, you can get a list of instances +by passing :code:`many=True` to this method. + + +To transform a :code:`ModelSerializer` instance into a recordset (de-serialization), do the following:: + + partner = partner_info.to_recordset() + +Unless an existing partner can be found (see below), this method **creates a new record** in the database. You can avoid +that by passing :code:`create=False`, in which case the system will only create them in memory (:code:`NewId` recordset). + +In order to determine if the corresponding Odoo record already exists or if a new one should be created, the system +checks by default if the :code:`id` field of the instance corresponds to a database record. This default behavior can be +modified like so:: + + class CountryInfo(ModelSerializer): + _name = "country.info" + _model = "res.country" + _model_fields = ["code", "name"] + + def get_odoo_record(self): + if self.code: + return self.env[self._model].search([("code", "=", self.code)]) + return super().get_odoo_record() diff --git a/model_serializer/serializers.py b/model_serializer/serializers.py new file mode 100644 index 000000000..ec04150f4 --- /dev/null +++ b/model_serializer/serializers.py @@ -0,0 +1,20 @@ +from .core import ModelSerializer + + +class GenericAbstractSerializer(ModelSerializer): + _name = "generic.abstract.serializer" + _model = "base" + _register = False + + def __init__(self, *args, **kwargs): + if kwargs.get("_model"): + self._model = kwargs.pop("_model") + super().__init__(*args, **kwargs) + + +class GenericMinimalSerializer(GenericAbstractSerializer): + _name = "generic.minimal.serializer" + _model_fields = ["id", "display_name"] + + def to_recordset(self): + return self.get_odoo_record() diff --git a/model_serializer/static/description/index.html b/model_serializer/static/description/index.html new file mode 100644 index 000000000..0c5ceeb9d --- /dev/null +++ b/model_serializer/static/description/index.html @@ -0,0 +1,540 @@ + + + + + + +Model Serializer + + + +
+

Model Serializer

+ + +

Alpha License: LGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runbot

+

This module takes advantage of the concepts introduced in the datamodel module to offer mechanisms similar to +(a subset of) the ModelSerializer in Django REST Framework. That is, use the definition of the Odoo model to +partially automate the definition of a corresponding Datamodel class.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Usage

+
+

ModelSerializer class

+

The ModelSerializer class inherits from the Datamodel class and adds functionalities. Therefore any +class inheriting from ModelSerializer can be used the exact same way as any other Datamodel.

+
+

Basic usage

+

Here is a basic example:

+
+from odoo.addons.model_serializer.core import ModelSerializer
+
+class PartnerInfo(ModelSerializer):
+    _name = "partner.info"
+    _model = "res.partner"
+    _model_fields = ["id", "name", "country_id"]
+
+

The result is equivalent to the following Datamodel classes:

+
+from marshmallow import fields
+
+from odoo.addons.datamodel.core import Datamodel
+from odoo.addons.datamodel.fields import NestedModel
+
+
+class PartnerInfo(Datamodel):
+    _name = "partner.info"
+
+    id = fields.Integer(required=True, allow_none=False, dump_only=True)
+    name = fields.String(required=True, allow_none=False)
+    country = NestedModel("_auto_nested_serializer.res.country")
+
+
+class _AutoNestedSerializerResCountry(Datamodel):
+    _name = "_auto_nested_serializer.res.country"
+
+    id = fields.Integer(required=True, allow_none=False, dump_only=True)
+    display_name = fields.String(dump_only=True)
+
+
+
+

Overriding fields definition

+

It is possible to override the default definition of fields as such:

+
+from odoo.addons.model_serializer.core import ModelSerializer
+
+class PartnerInfo(ModelSerializer):
+    _name = "partner.info"
+    _model = "res.partner"
+    _model_fields = ["id", "name", "country_id"]
+
+    country_id = NestedModel("country.info")
+
+class CountryInfo(ModelSerializer):
+    _name = "country.info"
+    _model = "res.country"
+    _model_fields = ["code", "name"]
+
+

In this example, we override a NestedModel but it works the same for any other field type.

+
+
+

(De)serialization

+

ModelSerializer does all the heavy-lifting of transforming a Datamodel instance into the corresponding +recordset, and vice-versa.

+

To transform a recordset into a (list of) ModelSerializer instance(s) (serialization), do the following:

+
+partner_info = self.env.datamodels["partner.info"].from_recordset(partner)
+
+

This will return a single instance; if your recordset contains more than one record, you can get a list of instances +by passing many=True to this method.

+

To transform a ModelSerializer instance into a recordset (de-serialization), do the following:

+
+partner = partner_info.to_recordset()
+
+

Unless an existing partner can be found (see below), this method creates a new record in the database. You can avoid +that by passing create=False, in which case the system will only create them in memory (NewId recordset).

+

In order to determine if the corresponding Odoo record already exists or if a new one should be created, the system +checks by default if the id field of the instance corresponds to a database record. This default behavior can be +modified like so:

+
+class CountryInfo(ModelSerializer):
+    _name = "country.info"
+    _model = "res.country"
+    _model_fields = ["code", "name"]
+
+    def get_odoo_record(self):
+        if self.code:
+            return self.env[self._model].search([("code", "=", self.code)])
+        return super().get_odoo_record()
+
+
+
+
+
+

Changelog

+
+

13.0.1.0.0

+

First official version.

+
+
+
+

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 smashing it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Wakari
  • +
+
+
+

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.

+

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/model_serializer/tests/__init__.py b/model_serializer/tests/__init__.py new file mode 100644 index 000000000..8c6802336 --- /dev/null +++ b/model_serializer/tests/__init__.py @@ -0,0 +1 @@ +from . import test_model_serializer diff --git a/model_serializer/tests/test_model_serializer.py b/model_serializer/tests/test_model_serializer.py new file mode 100644 index 000000000..559f389f8 --- /dev/null +++ b/model_serializer/tests/test_model_serializer.py @@ -0,0 +1,236 @@ +# Copyright 2021 Wakari SRL +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) +from marshmallow import fields + +from odoo import SUPERUSER_ID, fields as odoo_fields +from odoo.exceptions import ValidationError + +from odoo.addons.datamodel.fields import NestedModel +from odoo.addons.datamodel.tests.common import DatamodelRegistryCase + +from ..core import ModelSerializer +from ..field_converter import Binary +from ..serializers import GenericMinimalSerializer + + +def _schema_field(model_serializer_cls, field_name): + schema_cls = model_serializer_cls.__schema_class__ + return schema_cls._declared_fields.get(field_name) + + +class TestModelSerializer(DatamodelRegistryCase): + """Test build of ModelSerializer""" + + def setUp(self): + super().setUp() + self._full_build_model_serializer(GenericMinimalSerializer) + + def _full_build_model_serializer(self, model_serializer_cls): + model_serializer_cls._build_datamodel(self.datamodel_registry) + new_cls = model_serializer_cls._extend_from_odoo_model( + self.datamodel_registry, self.env + ) + new_cls._build_datamodel(self.datamodel_registry) + return self.env.datamodels[model_serializer_cls._name] + + def test_01_required_attrs(self): + """Ensure that ModelSerializer has mandatory attributes""" + msg = ".*require '_model' and '_model_fields' attributes.*" + with self.assertRaisesRegex(ValidationError, msg): + + class ModelSerializerBad1(ModelSerializer): + _name = "modelserializer_no_model" + + self._full_build_model_serializer(ModelSerializerBad1) + + with self.assertRaisesRegex(ValidationError, msg): + + class ModelSerializerBad2(ModelSerializer): + _name = "modelserializer_no_model_fields" + _model = "res.partner" + + self._full_build_model_serializer(ModelSerializerBad2) + + def test_02_has_field(self): + """Ensure that ModelSerializers have the generated fields""" + + class ModelSerializer1(ModelSerializer): + _name = "modelserializer1" + _model = "res.partner" + _model_fields = ["id"] + + class ModelSerializer2(ModelSerializer): + _name = "modelserializer2" + _inherit = "modelserializer1" + + for serializer_class in (ModelSerializer1, ModelSerializer2): + full_cls = self._full_build_model_serializer(serializer_class) + self.assertTrue(hasattr(full_cls, "id")) + self.assertIsInstance(_schema_field(full_cls, "id"), fields.Integer) + + def test_03_simple_field_converter(self): + """Ensure that non-relational fields are correctly converted""" + + fields_conversion = { + "id": (odoo_fields.Id, fields.Integer, {"dump_only": True}), + "create_date": (odoo_fields.Datetime, fields.DateTime, {"dump_only": True}), + "date": ( + odoo_fields.Date, + fields.Date, + {"required": False, "allow_none": True}, + ), + "name": ( + odoo_fields.Char, + fields.String, + {"required": False, "allow_none": True}, + ), + "comment": ( + odoo_fields.Text, + fields.String, + {"required": False, "allow_none": True}, + ), + "active": ( + odoo_fields.Boolean, + fields.Boolean, + {"required": False, "allow_none": True}, + ), + "partner_latitude": ( + odoo_fields.Float, + fields.Float, + {"required": False, "allow_none": True}, + ), + "active_lang_count": ( + odoo_fields.Integer, + fields.Integer, + {"dump_only": True}, + ), + "image_1920": ( + odoo_fields.Image, + Binary, + {"required": False, "allow_none": True}, + ), + } + + class PartnerSerializer(ModelSerializer): + _name = "partner_serializer" + _model = "res.partner" + _model_fields = list(fields_conversion) + + full_cls = self._full_build_model_serializer(PartnerSerializer) + for field_name in fields_conversion: + odoo_field_cls, marsh_field_cls, attrs = fields_conversion[field_name] + this_field = _schema_field(full_cls, field_name) + self.assertIsInstance(this_field, marsh_field_cls) + for attr, attr_val in attrs.items(): + msg = ( + "Error when converting field {}, wrong " + "attribute value ('{}' should be '{}')".format( + field_name, attr, attr_val + ), + ) + self.assertEqual(getattr(this_field, attr), attr_val, msg=msg) + + def test_04_relational_field_converter(self): + """Ensure that relational fields are correctly converted""" + + class PartnerSerializer(ModelSerializer): + _name = "partner_serializer" + _model = "res.partner" + _model_fields = ["user_id"] + + full_cls = self._full_build_model_serializer(PartnerSerializer) + user_field = _schema_field(full_cls, "user_id") + self.assertIsInstance(user_field, NestedModel) + self.assertEqual(user_field.datamodel_name, "generic.minimal.serializer") + self.assertFalse(user_field.many) + + def test_05_from_recordset(self): + """Test `from_recordset` method with only simple fields""" + + class PartnerSerializer(ModelSerializer): + _name = "partner_serializer" + _model = "res.partner" + _model_fields = ["id", "name"] + + datamodel_cls = self._full_build_model_serializer(PartnerSerializer) + self.partner = self.env["res.partner"].create({"name": "Test Partner"}) + datamodel = datamodel_cls.from_recordset(self.partner) + self.assertIsInstance(datamodel, datamodel_cls) + self.assertEqual(datamodel.id, self.partner.id) + self.assertEqual(datamodel.name, self.partner.name) + self.assertEqual( + set(PartnerSerializer._model_fields), + set(datamodel.__schema__.fields.keys()), + ) + + def test_06_from_recordset_nested(self): + """Test `from_recordset` method with nested fields, default nested serializer""" + + class PartnerSerializer(ModelSerializer): + _name = "partner_serializer" + _model = "res.partner" + _model_fields = ["user_id"] + + datamodel_cls = self._full_build_model_serializer(PartnerSerializer) + self.partner = self.env["res.partner"].create({"name": "Test Partner"}) + datamodel1 = datamodel_cls.from_recordset(self.partner) + self.assertIsInstance(datamodel1, datamodel_cls) + self.assertEqual(datamodel1.user_id, None) + self.partner.write({"user_id": SUPERUSER_ID}) + datamodel2 = datamodel_cls.from_recordset(self.partner) + self.assertIsInstance(datamodel2, datamodel_cls) + self.assertEqual(datamodel2.user_id.id, SUPERUSER_ID) + self.assertEqual( + datamodel2.user_id.display_name, self.partner.user_id.display_name + ) + self.assertEqual( + set(PartnerSerializer._model_fields), + set(datamodel2.__schema__.fields.keys()), + ) + + def test_07_from_recordset_nested_custom(self): + """Test `from_recordset` method with nested fields, custom nested serializer""" + + class PartnerSerializer(ModelSerializer): + _name = "partner_serializer" + _model = "res.partner" + _model_fields = ["user_id"] + + user_id = NestedModel("user_serializer") + + class UserSerializer(ModelSerializer): + _name = "user_serializer" + _model = "res.users" + _model_fields = ["login"] + + user_dm_cls = self._full_build_model_serializer(UserSerializer) + datamodel_cls = self._full_build_model_serializer(PartnerSerializer) + user_field = _schema_field(datamodel_cls, "user_id") + self.assertEqual(user_field.datamodel_name, "user_serializer") + self.partner = self.env["res.partner"].create( + {"name": "Test Partner", "user_id": SUPERUSER_ID} + ) + datamodel = datamodel_cls.from_recordset(self.partner) + self.assertIsInstance(datamodel.user_id, user_dm_cls) + self.assertEqual(datamodel.user_id.login, self.partner.user_id.login) + self.assertEqual( + set(UserSerializer._model_fields), + set(datamodel.user_id.__schema__.fields.keys()), + ) + + def test_08_to_recordset_write(self): + """Test `to_recordset` method with only simple fields, existing partner""" + + class PartnerSerializer(ModelSerializer): + _name = "partner_serializer" + _model = "res.partner" + _model_fields = ["id", "name"] + + self.partner = self.env["res.partner"].create({"name": "Test Partner"}) + datamodel_cls = self._full_build_model_serializer(PartnerSerializer) + datamodel = datamodel_cls(partial=True) + datamodel.id = self.partner.id + datamodel.name = self.partner.name + "New" + new_partner = datamodel.to_recordset() + self.assertEqual(new_partner, self.partner) + self.assertEqual(new_partner.name, datamodel.name)