From febbe6ede10012a6d9ac17abf60e0a8b3c54f4be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Degrave?= Date: Thu, 1 Jul 2021 14:47:57 +0200 Subject: [PATCH 01/13] [ADD] model_serializer: module to facilitate the (de)serialization of Odoo Models --- model_serializer/__init__.py | 1 + model_serializer/__manifest__.py | 15 ++ model_serializer/datamodels/__init__.py | 1 + .../datamodels/field_converter.py | 132 ++++++++++++++ .../datamodels/model_serializer.py | 168 ++++++++++++++++++ 5 files changed, 317 insertions(+) create mode 100644 model_serializer/__init__.py create mode 100644 model_serializer/__manifest__.py create mode 100644 model_serializer/datamodels/__init__.py create mode 100644 model_serializer/datamodels/field_converter.py create mode 100644 model_serializer/datamodels/model_serializer.py diff --git a/model_serializer/__init__.py b/model_serializer/__init__.py new file mode 100644 index 000000000..60eeb7351 --- /dev/null +++ b/model_serializer/__init__.py @@ -0,0 +1 @@ +from . import datamodels 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/datamodels/__init__.py b/model_serializer/datamodels/__init__.py new file mode 100644 index 000000000..b7a8028d0 --- /dev/null +++ b/model_serializer/datamodels/__init__.py @@ -0,0 +1 @@ +from . import field_converter, model_serializer diff --git a/model_serializer/datamodels/field_converter.py b/model_serializer/datamodels/field_converter.py new file mode 100644 index 000000000..c9c85c60a --- /dev/null +++ b/model_serializer/datamodels/field_converter.py @@ -0,0 +1,132 @@ +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) + ) + target_model = self.odoo_field.comodel_name + nested_class = "_auto_nested_serializer.{}".format(target_model) + kwargs["nested"] = nested_class + 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/datamodels/model_serializer.py b/model_serializer/datamodels/model_serializer.py new file mode 100644 index 000000000..100085ef3 --- /dev/null +++ b/model_serializer/datamodels/model_serializer.py @@ -0,0 +1,168 @@ +from odoo import _, models +from odoo.exceptions import ValidationError + +from odoo.addons.datamodel.core import Datamodel, MetaDatamodel, _datamodel_databases + +from .field_converter import convert_field + + +def to_camelcase(txt): + tokens = [t.title() for t in txt.split("._")] + return "".join(tokens) + + +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 _build_default_nested_class(self, marshmallow_field, odoo_field, registry): + """If `marshmallow_field` is a nested datamodel (relational field), we build + a default model_serializer class (if it does not exist yet). + + The default model_serializer simply return the `display_name` and the `id` + """ + nested_name = getattr(marshmallow_field, "datamodel_name", None) + if nested_name and nested_name not in registry: + nested_attrs = { + "_name": nested_name, + "_model": odoo_field.comodel_name, + "_model_fields": ["id", "display_name"], + } + nested_class = MetaDatamodel( + to_camelcase(nested_name), (ModelSerializer,), nested_attrs + ) + registry[nested_name] = nested_class + nested_class._build_datamodel(registry) + self._extend_model_serializer(nested_class, 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): + attrs = { + "_inherit": datamodel_class._name, + "_model_fields": datamodel_class._model_fields, + "_model": datamodel_class._model, + } + bases = (ModelSerializer,) + name = datamodel_class.__name__ + "Child" + odoo_model = self.env[datamodel_class._model] + for field_name in datamodel_class._model_fields: + if not hasattr(datamodel_class, field_name): + odoo_field = odoo_model._fields[field_name] + marshmallow_field = convert_field(odoo_field) + if marshmallow_field: + attrs[field_name] = marshmallow_field + self._build_default_nested_class( + marshmallow_field, odoo_field, registry + ) + + parent_class = registry[datamodel_class._name] + if getattr(parent_class, "_model_fields", None): + _model_fields = list( + set(attrs["_model_fields"] + parent_class._model_fields) + ) + attrs["_model_fields"] = _model_fields + new_class = MetaDatamodel(name, bases, attrs) + new_class._build_datamodel(registry) + + +class MetaModelSerializer(MetaDatamodel): + def __init__(self, name, bases, attrs): + register = attrs.get("_register") + if register and not (attrs.get("_model") and attrs.get("_model_fields")): + raise ValidationError( + _( + "Model Serializers require '_model' and '_model_fields' " + "attributes to be defined" + ) + ) + super(MetaModelSerializer, self).__init__(name, bases, attrs) + + +class ModelSerializer(Datamodel, metaclass=MetaModelSerializer): + _inherit = "base" + _register = False + _model = None + _model_fields = [] + + @classmethod + def from_recordset(cls, recordset, many=False): + """Transform a recordset into a (list of) datamodel(s)""" + res = [] + if not many: + recordset = recordset[:1] + for record in recordset: + instance = cls(partial=True) + 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 and record[model_field]: + nested_datamodel_class = recordset.env.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 = ( + None if record[model_field] is False else record[model_field] + ) + setattr(instance, model_field, value) + res.append(instance) + if res and not many: + return res[0] + return res + + def get_odoo_record(self): + """Get an existing record matching `self`. Meant to be overridden""" + model = self.env[self._model] + if "id" in self._model_fields and getattr(self, "id", None): + return model.browse(self.id) + default_values = model.default_get(model._fields.keys()) + return self.env[self._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 to_recordset(self, create=True): + """Create or modify a recordset (singleton) related to self""" + res = self.get_odoo_record() + self_fields = ( + self.dump().keys() + ) # in case of partial not all fields are considered + model_fields = set(self_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) + if nested_datamodel_name: + comodel = self.env[res._fields[model_field].comodel_name] + value = [value] if isinstance(value, Datamodel) else value + value = comodel.union( + *[ + nested_instance.to_recordset(create=False) + for nested_instance in value + ] + ) + res[model_field] = self._process_model_value(value, model_field) + if create and isinstance(res.id, models.NewId): + values = {field_name: res[field_name] for field_name in res._cache} + values = res._convert_to_write(values) + res = res.create(values) + return res From f936a904c75e8501f380fd962c1aba93503d9e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Degrave?= Date: Thu, 1 Jul 2021 21:19:17 +0200 Subject: [PATCH 02/13] [REF] model_serializer: directories modifications + [ADD] readme --- __init__.py | 0 model_serializer/__init__.py | 2 +- .../{datamodels/model_serializer.py => core.py} | 0 model_serializer/datamodels/__init__.py | 1 - .../{datamodels => }/field_converter.py | 0 model_serializer/readme/CONTRIBUTORS.rst | 1 + model_serializer/readme/DESCRIPTION.rst | 3 +++ model_serializer/readme/HISTORY.rst | 4 ++++ model_serializer/readme/USAGE.rst | 14 ++++++++++++++ 9 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 __init__.py rename model_serializer/{datamodels/model_serializer.py => core.py} (100%) delete mode 100644 model_serializer/datamodels/__init__.py rename model_serializer/{datamodels => }/field_converter.py (100%) create mode 100644 model_serializer/readme/CONTRIBUTORS.rst create mode 100644 model_serializer/readme/DESCRIPTION.rst create mode 100644 model_serializer/readme/HISTORY.rst create mode 100644 model_serializer/readme/USAGE.rst diff --git a/__init__.py b/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/model_serializer/__init__.py b/model_serializer/__init__.py index 60eeb7351..be67ef3f7 100644 --- a/model_serializer/__init__.py +++ b/model_serializer/__init__.py @@ -1 +1 @@ -from . import datamodels +from . import field_converter, core diff --git a/model_serializer/datamodels/model_serializer.py b/model_serializer/core.py similarity index 100% rename from model_serializer/datamodels/model_serializer.py rename to model_serializer/core.py diff --git a/model_serializer/datamodels/__init__.py b/model_serializer/datamodels/__init__.py deleted file mode 100644 index b7a8028d0..000000000 --- a/model_serializer/datamodels/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import field_converter, model_serializer diff --git a/model_serializer/datamodels/field_converter.py b/model_serializer/field_converter.py similarity index 100% rename from model_serializer/datamodels/field_converter.py rename to model_serializer/field_converter.py 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..8579c29d0 --- /dev/null +++ b/model_serializer/readme/USAGE.rst @@ -0,0 +1,14 @@ +: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 example:: + + from odoo.addons.model_serializer.core import ModelSerializer + + class PartnerInfo(ModelSerializer): + _name = "partner.info" + _model = "res.partner" + _model_fields = ["id", "name", "country_id"] From 860bcace0176a93a7346a126893e8ffad7e80541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Degrave?= Date: Fri, 2 Jul 2021 18:14:44 +0200 Subject: [PATCH 03/13] [IMP] model_serializer: documentation --- model_serializer/README.rst | 189 ++++++ model_serializer/core.py | 8 +- model_serializer/readme/USAGE.rst | 84 ++- .../static/description/index.html | 540 ++++++++++++++++++ 4 files changed, 818 insertions(+), 3 deletions(-) create mode 100644 model_serializer/README.rst create mode 100644 model_serializer/static/description/index.html diff --git a/model_serializer/README.rst b/model_serializer/README.rst new file mode 100644 index 000000000..aa0b9c262 --- /dev/null +++ b/model_serializer/README.rst @@ -0,0 +1,189 @@ +================ +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 = 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/core.py b/model_serializer/core.py index 100085ef3..fb432fd2b 100644 --- a/model_serializer/core.py +++ b/model_serializer/core.py @@ -26,7 +26,7 @@ def _build_default_nested_class(self, marshmallow_field, odoo_field, registry): """If `marshmallow_field` is a nested datamodel (relational field), we build a default model_serializer class (if it does not exist yet). - The default model_serializer simply return the `display_name` and the `id` + The default model_serializer simply returns the `display_name` and the `id` """ nested_name = getattr(marshmallow_field, "datamodel_name", None) if nested_name and nested_name not in registry: @@ -130,6 +130,10 @@ def get_odoo_record(self): model = self.env[self._model] if "id" in self._model_fields and getattr(self, "id", None): return model.browse(self.id) + return self._new_odoo_record() + + def _new_odoo_record(self): + model = self.env[self._model] default_values = model.default_get(model._fields.keys()) return self.env[self._model].new(default_values) @@ -140,7 +144,7 @@ def _process_model_value(self, value, model_field): def to_recordset(self, create=True): """Create or modify a recordset (singleton) related to self""" - res = self.get_odoo_record() + res = self.get_odoo_record() or self._new_odoo_record() self_fields = ( self.dump().keys() ) # in case of partial not all fields are considered diff --git a/model_serializer/readme/USAGE.rst b/model_serializer/readme/USAGE.rst index 8579c29d0..f954b8077 100644 --- a/model_serializer/readme/USAGE.rst +++ b/model_serializer/readme/USAGE.rst @@ -4,7 +4,45 @@ 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 example:: +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 @@ -12,3 +50,47 @@ Basic example:: _name = "partner.info" _model = "res.partner" _model_fields = ["id", "name", "country_id"] + + country = 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/static/description/index.html b/model_serializer/static/description/index.html new file mode 100644 index 000000000..4b110dd80 --- /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 = 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.

+
+
+
+ + From 4086bcc0333812b993774b2917667124734d5da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Degrave?= Date: Thu, 8 Jul 2021 09:15:54 +0200 Subject: [PATCH 04/13] [REF] model_serializer: refactoring + [ADD] tests --- model_serializer/README.rst | 2 +- model_serializer/__init__.py | 5 +- model_serializer/builder.py | 23 ++ model_serializer/core.py | 258 ++++++++++-------- model_serializer/field_converter.py | 5 +- model_serializer/serializers.py | 17 ++ model_serializer/tests/__init__.py | 1 + .../tests/test_model_serializer.py | 236 ++++++++++++++++ 8 files changed, 428 insertions(+), 119 deletions(-) create mode 100644 model_serializer/builder.py create mode 100644 model_serializer/serializers.py create mode 100644 model_serializer/tests/__init__.py create mode 100644 model_serializer/tests/test_model_serializer.py diff --git a/model_serializer/README.rst b/model_serializer/README.rst index aa0b9c262..a6df95c97 100644 --- a/model_serializer/README.rst +++ b/model_serializer/README.rst @@ -23,7 +23,7 @@ Model Serializer :target: https://runbot.odoo-community.org/runbot/271/13.0 :alt: Try me on Runbot -|badge1| |badge2| |badge3| |badge4| |badge5| +|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 diff --git a/model_serializer/__init__.py b/model_serializer/__init__.py index be67ef3f7..a42838e17 100644 --- a/model_serializer/__init__.py +++ b/model_serializer/__init__.py @@ -1 +1,4 @@ -from . import field_converter, core +from . import builder +from . import core +from . import field_converter +from . import serializers 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 index fb432fd2b..e87c51f0a 100644 --- a/model_serializer/core.py +++ b/model_serializer/core.py @@ -1,112 +1,116 @@ from odoo import _, models from odoo.exceptions import ValidationError -from odoo.addons.datamodel.core import Datamodel, MetaDatamodel, _datamodel_databases +from odoo.addons.datamodel.core import Datamodel, MetaDatamodel from .field_converter import convert_field -def to_camelcase(txt): - tokens = [t.title() for t in txt.split("._")] - return "".join(tokens) +class ClassOrInstanceMethod(classmethod): + def __get__(self, instance, type_): + descr_get = super().__get__ if instance is None else self.__func__.__get__ + return descr_get(instance, type_) -class DatamodelBuilder(models.AbstractModel): - _inherit = "datamodel.builder" +class MetaModelSerializer(MetaDatamodel): + def __init__(self, name, bases, attrs): + super(MetaModelSerializer, self).__init__(name, bases, attrs) - 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 _build_default_nested_class(self, marshmallow_field, odoo_field, registry): - """If `marshmallow_field` is a nested datamodel (relational field), we build - a default model_serializer class (if it does not exist yet). +class ModelSerializer(Datamodel, metaclass=MetaModelSerializer): + _inherit = "base" + _register = False + _model = None + _model_fields = [] - The default model_serializer simply returns the `display_name` and the `id` + @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: - nested_attrs = { - "_name": nested_name, - "_model": odoo_field.comodel_name, - "_model_fields": ["id", "display_name"], - } - nested_class = MetaDatamodel( - to_camelcase(nested_name), (ModelSerializer,), nested_attrs + raise ValidationError( + _("'{}' datamodel does not exist").format(nested_name) ) - registry[nested_name] = nested_class - nested_class._build_datamodel(registry) - self._extend_model_serializer(nested_class, 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): - attrs = { - "_inherit": datamodel_class._name, - "_model_fields": datamodel_class._model_fields, - "_model": datamodel_class._model, - } - bases = (ModelSerializer,) - name = datamodel_class.__name__ + "Child" - odoo_model = self.env[datamodel_class._model] - for field_name in datamodel_class._model_fields: - if not hasattr(datamodel_class, field_name): - odoo_field = odoo_model._fields[field_name] - marshmallow_field = convert_field(odoo_field) - if marshmallow_field: - attrs[field_name] = marshmallow_field - self._build_default_nested_class( - marshmallow_field, odoo_field, registry - ) - parent_class = registry[datamodel_class._name] - if getattr(parent_class, "_model_fields", None): - _model_fields = list( - set(attrs["_model_fields"] + parent_class._model_fields) + @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_fields"] = _model_fields - new_class = MetaDatamodel(name, bases, attrs) - new_class._build_datamodel(registry) - + attrs["_model"] = parent_class._model -class MetaModelSerializer(MetaDatamodel): - def __init__(self, name, bases, attrs): - register = attrs.get("_register") - if register and not (attrs.get("_model") and attrs.get("_model_fields")): + if not (attrs["_model"] and has_model_fields): raise ValidationError( _( - "Model Serializers require '_model' and '_model_fields' " - "attributes to be defined" - ) + "Error in {}: Model Serializers require '_model' and " + "'_model_fields' attributes to be defined" + ).format(cls.__name__) ) - super(MetaModelSerializer, self).__init__(name, bases, attrs) - -class ModelSerializer(Datamodel, metaclass=MetaModelSerializer): - _inherit = "base" - _register = False - _model = None - _model_fields = [] + 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 = getattr(cls.__schema_class__, 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): + 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 = [] - if not many: - recordset = recordset[:1] + datamodels = recordset.env.datamodels + recordset = recordset if many else recordset[:1] for record in recordset: - instance = cls(partial=True) + 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 and record[model_field]: - nested_datamodel_class = recordset.env.datamodels[ - nested_datamodel_name - ] + nested_datamodel_class = datamodels[nested_datamodel_name] if hasattr(nested_datamodel_class, "from_recordset"): setattr( instance, @@ -116,9 +120,7 @@ def from_recordset(cls, recordset, many=False): ), ) else: - value = ( - None if record[model_field] is False else record[model_field] - ) + value = convert_null_value(record[model_field]) setattr(instance, model_field, value) res.append(instance) if res and not many: @@ -126,47 +128,75 @@ def from_recordset(cls, recordset, many=False): return res def get_odoo_record(self): - """Get an existing record matching `self`. Meant to be overridden""" - model = self.env[self._model] + """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 model.browse(self.id) + return odoo_model.browse(self.id) return self._new_odoo_record() def _new_odoo_record(self): - model = self.env[self._model] - default_values = model.default_get(model._fields.keys()) - return self.env[self._model].new(default_values) + 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 to_recordset(self, create=True): - """Create or modify a recordset (singleton) related to self""" - res = self.get_odoo_record() or self._new_odoo_record() - self_fields = ( - self.dump().keys() - ) # in case of partial not all fields are considered - model_fields = set(self_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) - if nested_datamodel_name: - comodel = self.env[res._fields[model_field].comodel_name] - value = [value] if isinstance(value, Datamodel) else value - value = comodel.union( - *[ - nested_instance.to_recordset(create=False) - for nested_instance in value - ] - ) - res[model_field] = self._process_model_value(value, model_field) - if create and isinstance(res.id, models.NewId): - values = {field_name: res[field_name] for field_name in res._cache} - values = res._convert_to_write(values) - res = res.create(values) - return res + @classmethod + def _many_to_recordset(cls, instances, create=True, start=None): + """Transform `instances` into a corresponding recordset + + :param instances: datamodels to transform + :param create: whether to create new records or keep them in memory + :param start: if instances is empty, or if the serializer is generic (used + for different models), allows to determine the target Odoo + model + :return: a recordset + """ + if not instances: + return start if isinstance(start, models.BaseModel) else [] + env = instances[0].env + recordset = ( + start + if isinstance(start, models.BaseModel) + else env[instances[0]._model].browse([]) + ) + model_name = recordset._name + for instance in instances: + instance._model_name = model_name + record = instance.get_odoo_record() or instance._new_odoo_record() + # in case of partial, not all fields are considered + self_fields = instance.dump().keys() + model_fields = set(self_fields) & set(instance._model_fields) + for model_field in model_fields: + schema_field = instance.__schema__.fields[model_field] + if schema_field.dump_only: + continue + value = getattr(instance, model_field) + nested_datamodel_name = getattr(schema_field, "datamodel_name", None) + if nested_datamodel_name: + comodel = instance.env[record._fields[model_field].comodel_name] + nested_instances = value if isinstance(value, list) else [value] + nested_start = comodel.browse([]) + value = env.datamodels[nested_datamodel_name]._many_to_recordset( + nested_instances, create=False, start=nested_start + ) + record[model_field] = instance._process_model_value(value, model_field) + if create and isinstance(record.id, models.NewId): + values = { + field_name: record[field_name] for field_name in record._cache + } + values = record._convert_to_write(values) + record = record.create(values) + recordset += record + return record + + @ClassOrInstanceMethod + def to_recordset(self, *, instances=None, create=True, start=None): + if instances is None and isinstance(self, ModelSerializer): + instances = [self] + return self._many_to_recordset(instances, create=create, start=start) diff --git a/model_serializer/field_converter.py b/model_serializer/field_converter.py index c9c85c60a..1dccae1b4 100644 --- a/model_serializer/field_converter.py +++ b/model_serializer/field_converter.py @@ -92,9 +92,8 @@ def _get_kwargs(self): kwargs["many"] = isinstance( self.odoo_field, (odoo_fields.One2many, odoo_fields.Many2many) ) - target_model = self.odoo_field.comodel_name - nested_class = "_auto_nested_serializer.{}".format(target_model) - kwargs["nested"] = nested_class + kwargs["nested"] = "generic.minimal.serializer" + kwargs["metadata"] = {"odoo_model": self.odoo_field.comodel_name} return kwargs def _marshmallow_field_class(self): diff --git a/model_serializer/serializers.py b/model_serializer/serializers.py new file mode 100644 index 000000000..2efa7315c --- /dev/null +++ b/model_serializer/serializers.py @@ -0,0 +1,17 @@ +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"] 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..7010c9ccc --- /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 + + +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): + self._full_build_model_serializer(serializer_class) + full_cls = self.env.datamodels[serializer_class._name] + self.assertTrue(hasattr(full_cls, "id")) + schema_cls = full_cls.__schema_class__ + self.assertIsInstance(schema_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) + schema_cls = full_cls.__schema_class__ + for field_name in fields_conversion: + odoo_field_cls, marsh_field_cls, attrs = fields_conversion[field_name] + this_field = getattr(schema_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) + schema_cls = full_cls.__schema_class__ + self.assertIsInstance(schema_cls.user_id, NestedModel) + self.assertEqual( + schema_cls.user_id.datamodel_name, "generic.minimal.serializer" + ) + self.assertFalse(schema_cls.user_id.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) + schema_cls = datamodel_cls.__schema_class__ + self.assertEqual(schema_cls.user_id.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) From 1c1ed6e4b97e8d4201fbae60787be4e42d16eac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Degrave?= Date: Thu, 8 Jul 2021 09:25:26 +0200 Subject: [PATCH 05/13] [IMP] model_serializer: ignore camel case verification on decorator definition "class_or_instancemethod" --- model_serializer/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/model_serializer/core.py b/model_serializer/core.py index e87c51f0a..cecf40013 100644 --- a/model_serializer/core.py +++ b/model_serializer/core.py @@ -6,7 +6,7 @@ from .field_converter import convert_field -class ClassOrInstanceMethod(classmethod): +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_) @@ -195,7 +195,7 @@ def _many_to_recordset(cls, instances, create=True, start=None): recordset += record return record - @ClassOrInstanceMethod + @class_or_instancemethod def to_recordset(self, *, instances=None, create=True, start=None): if instances is None and isinstance(self, ModelSerializer): instances = [self] From f67162b28ab3ef1b0263feee4f932fc1147f1508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Degrave?= Date: Thu, 8 Jul 2021 11:17:54 +0200 Subject: [PATCH 06/13] [FIX] model_serializer: tests failed on python 3.6 (worked on 3.8) --- model_serializer/core.py | 2 +- .../tests/test_model_serializer.py | 28 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/model_serializer/core.py b/model_serializer/core.py index cecf40013..3ee7554ad 100644 --- a/model_serializer/core.py +++ b/model_serializer/core.py @@ -74,7 +74,7 @@ def _extend_from_odoo_model(cls, registry, env): if marshmallow_field: attrs[field_name] = marshmallow_field else: - marshmallow_field = getattr(cls.__schema_class__, field_name) + marshmallow_field = cls.__schema_class__._declared_fields[field_name] cls._check_nested_class(marshmallow_field, registry) return MetaDatamodel(name, bases, attrs) diff --git a/model_serializer/tests/test_model_serializer.py b/model_serializer/tests/test_model_serializer.py index 7010c9ccc..559f389f8 100644 --- a/model_serializer/tests/test_model_serializer.py +++ b/model_serializer/tests/test_model_serializer.py @@ -13,6 +13,11 @@ 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""" @@ -59,11 +64,9 @@ class ModelSerializer2(ModelSerializer): _inherit = "modelserializer1" for serializer_class in (ModelSerializer1, ModelSerializer2): - self._full_build_model_serializer(serializer_class) - full_cls = self.env.datamodels[serializer_class._name] + full_cls = self._full_build_model_serializer(serializer_class) self.assertTrue(hasattr(full_cls, "id")) - schema_cls = full_cls.__schema_class__ - self.assertIsInstance(schema_cls.id, fields.Integer) + self.assertIsInstance(_schema_field(full_cls, "id"), fields.Integer) def test_03_simple_field_converter(self): """Ensure that non-relational fields are correctly converted""" @@ -114,10 +117,9 @@ class PartnerSerializer(ModelSerializer): _model_fields = list(fields_conversion) full_cls = self._full_build_model_serializer(PartnerSerializer) - schema_cls = full_cls.__schema_class__ for field_name in fields_conversion: odoo_field_cls, marsh_field_cls, attrs = fields_conversion[field_name] - this_field = getattr(schema_cls, 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 = ( @@ -137,12 +139,10 @@ class PartnerSerializer(ModelSerializer): _model_fields = ["user_id"] full_cls = self._full_build_model_serializer(PartnerSerializer) - schema_cls = full_cls.__schema_class__ - self.assertIsInstance(schema_cls.user_id, NestedModel) - self.assertEqual( - schema_cls.user_id.datamodel_name, "generic.minimal.serializer" - ) - self.assertFalse(schema_cls.user_id.many) + 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""" @@ -205,8 +205,8 @@ class UserSerializer(ModelSerializer): user_dm_cls = self._full_build_model_serializer(UserSerializer) datamodel_cls = self._full_build_model_serializer(PartnerSerializer) - schema_cls = datamodel_cls.__schema_class__ - self.assertEqual(schema_cls.user_id.datamodel_name, "user_serializer") + 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} ) From 4167ddebf6a2d1b2c8b9b2001336dcf86b11139e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Degrave?= Date: Thu, 8 Jul 2021 11:39:33 +0200 Subject: [PATCH 07/13] [DOC] model_serializer --- model_serializer/README.rst | 11 +++++------ model_serializer/readme/USAGE.rst | 8 ++++---- model_serializer/static/description/index.html | 8 ++++---- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/model_serializer/README.rst b/model_serializer/README.rst index a6df95c97..4985a5363 100644 --- a/model_serializer/README.rst +++ b/model_serializer/README.rst @@ -23,7 +23,7 @@ Model Serializer :target: https://runbot.odoo-community.org/runbot/271/13.0 :alt: Try me on Runbot -|badge1| |badge2| |badge3| |badge4| |badge5| +|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 @@ -134,11 +134,10 @@ modified like so:: _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() - + def get_odoo_record(self): + if self.code: + return self.env[self._model].search([("code", "=", self.code)]) + return super().get_odoo_record() Changelog ========= diff --git a/model_serializer/readme/USAGE.rst b/model_serializer/readme/USAGE.rst index f954b8077..bccc7766b 100644 --- a/model_serializer/readme/USAGE.rst +++ b/model_serializer/readme/USAGE.rst @@ -90,7 +90,7 @@ modified like so:: _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() + 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/static/description/index.html b/model_serializer/static/description/index.html index 4b110dd80..8581ae17c 100644 --- a/model_serializer/static/description/index.html +++ b/model_serializer/static/description/index.html @@ -487,10 +487,10 @@

(De)serialization

_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() + def get_odoo_record(self): + if self.code: + return self.env[self._model].search([("code", "=", self.code)]) + return super().get_odoo_record() From 132553e768a0a142fe3bfd2c3e57461e53c07e67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Degrave?= Date: Thu, 8 Jul 2021 13:35:45 +0200 Subject: [PATCH 08/13] [DOC] model_serializer --- model_serializer/readme/USAGE.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model_serializer/readme/USAGE.rst b/model_serializer/readme/USAGE.rst index bccc7766b..47917ef4b 100644 --- a/model_serializer/readme/USAGE.rst +++ b/model_serializer/readme/USAGE.rst @@ -51,7 +51,7 @@ It is possible to override the default definition of fields as such:: _model = "res.partner" _model_fields = ["id", "name", "country_id"] - country = NestedModel("country.info") + country_id = NestedModel("country.info") class CountryInfo(ModelSerializer): _name = "country.info" From e86d7b7214a6ccf6e2e13ca1c2f7a512a05947f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Degrave?= Date: Thu, 8 Jul 2021 15:36:58 +0200 Subject: [PATCH 09/13] [FIX] model_serializer: better management of multiple nested objects --- model_serializer/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/model_serializer/core.py b/model_serializer/core.py index 3ee7554ad..5074345d1 100644 --- a/model_serializer/core.py +++ b/model_serializer/core.py @@ -109,7 +109,7 @@ def convert_null_value(val): 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 and record[model_field]: + if nested_datamodel_name: nested_datamodel_class = datamodels[nested_datamodel_name] if hasattr(nested_datamodel_class, "from_recordset"): setattr( @@ -123,8 +123,8 @@ def convert_null_value(val): value = convert_null_value(record[model_field]) setattr(instance, model_field, value) res.append(instance) - if res and not many: - return res[0] + if not many: + return res[0] if res else None return res def get_odoo_record(self): From 1cc9c27fe449d63cb391ebdeec2359be96830a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Degrave?= Date: Thu, 8 Jul 2021 15:38:22 +0200 Subject: [PATCH 10/13] [DOC] model_serializer --- model_serializer/README.rst | 2 +- model_serializer/static/description/index.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/model_serializer/README.rst b/model_serializer/README.rst index 4985a5363..880ecb6fa 100644 --- a/model_serializer/README.rst +++ b/model_serializer/README.rst @@ -95,7 +95,7 @@ It is possible to override the default definition of fields as such:: _model = "res.partner" _model_fields = ["id", "name", "country_id"] - country = NestedModel("country.info") + country_id = NestedModel("country.info") class CountryInfo(ModelSerializer): _name = "country.info" diff --git a/model_serializer/static/description/index.html b/model_serializer/static/description/index.html index 8581ae17c..0c5ceeb9d 100644 --- a/model_serializer/static/description/index.html +++ b/model_serializer/static/description/index.html @@ -453,7 +453,7 @@

Overriding fields definition

_model = "res.partner" _model_fields = ["id", "name", "country_id"] - country = NestedModel("country.info") + country_id = NestedModel("country.info") class CountryInfo(ModelSerializer): _name = "country.info" From 9c525d3797b73194d46394b7a59271b954dd1937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Degrave?= Date: Thu, 8 Jul 2021 17:40:41 +0200 Subject: [PATCH 11/13] [FIX][IMP] model_serializer: play along better with "data_key" field attribute --- model_serializer/core.py | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/model_serializer/core.py b/model_serializer/core.py index 5074345d1..23ba0fbe1 100644 --- a/model_serializer/core.py +++ b/model_serializer/core.py @@ -146,8 +146,21 @@ def _process_model_value(self, value, 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().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 + @classmethod - def _many_to_recordset(cls, instances, create=True, start=None): + def _many_to_recordset(cls, instances, create=True, write=True, start=None): """Transform `instances` into a corresponding recordset :param instances: datamodels to transform @@ -170,8 +183,8 @@ def _many_to_recordset(cls, instances, create=True, start=None): instance._model_name = model_name record = instance.get_odoo_record() or instance._new_odoo_record() # in case of partial, not all fields are considered - self_fields = instance.dump().keys() - model_fields = set(self_fields) & set(instance._model_fields) + received_fields = instance._get_partial_fields() + model_fields = set(received_fields) & set(instance._model_fields) for model_field in model_fields: schema_field = instance.__schema__.fields[model_field] if schema_field.dump_only: @@ -186,17 +199,19 @@ def _many_to_recordset(cls, instances, create=True, start=None): nested_instances, create=False, start=nested_start ) record[model_field] = instance._process_model_value(value, model_field) - if create and isinstance(record.id, models.NewId): - values = { - field_name: record[field_name] for field_name in record._cache - } - values = record._convert_to_write(values) + values = {field_name: record[field_name] for field_name in model_fields} + values = record._convert_to_write(values) + if isinstance(record.id, models.NewId) and create: record = record.create(values) + if isinstance(record.id, int) and write: + record.write(values) recordset += record return record @class_or_instancemethod - def to_recordset(self, *, instances=None, create=True, start=None): + def to_recordset(self, *, instances=None, create=True, write=True, start=None): if instances is None and isinstance(self, ModelSerializer): instances = [self] - return self._many_to_recordset(instances, create=create, start=start) + return self._many_to_recordset( + instances, create=create, write=write, start=start + ) From 3bae28bf577b265d99d9bc5c45e221bac89d4ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Degrave?= Date: Thu, 15 Jul 2021 11:26:23 +0200 Subject: [PATCH 12/13] [FIX] modelserializer: fix to_recordset for nested fields --- model_serializer/core.py | 117 ++++++++++++++++---------------- model_serializer/serializers.py | 3 + 2 files changed, 62 insertions(+), 58 deletions(-) diff --git a/model_serializer/core.py b/model_serializer/core.py index 23ba0fbe1..768761208 100644 --- a/model_serializer/core.py +++ b/model_serializer/core.py @@ -134,7 +134,7 @@ def get_odoo_record(self): 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 self._new_odoo_record() + return odoo_model.browse([]) def _new_odoo_record(self): odoo_model = self.env[self._model_name] @@ -149,7 +149,7 @@ def _process_model_value(self, value, model_field): def _get_partial_fields(self): """Return the list of fields actually used to instantiate `self`""" res = [] - received_keys = self.dump().keys() + received_keys = self.dump(many=False).keys() actual_field_names = { field.data_key: name for name, field in self.__schema__._declared_fields.items() @@ -159,59 +159,60 @@ def _get_partial_fields(self): res.append(actual_field_names.get(received_key) or received_key) return res - @classmethod - def _many_to_recordset(cls, instances, create=True, write=True, start=None): - """Transform `instances` into a corresponding recordset - - :param instances: datamodels to transform - :param create: whether to create new records or keep them in memory - :param start: if instances is empty, or if the serializer is generic (used - for different models), allows to determine the target Odoo - model - :return: a recordset - """ - if not instances: - return start if isinstance(start, models.BaseModel) else [] - env = instances[0].env - recordset = ( - start - if isinstance(start, models.BaseModel) - else env[instances[0]._model].browse([]) - ) - model_name = recordset._name - for instance in instances: - instance._model_name = model_name - record = instance.get_odoo_record() or instance._new_odoo_record() - # in case of partial, not all fields are considered - received_fields = instance._get_partial_fields() - model_fields = set(received_fields) & set(instance._model_fields) - for model_field in model_fields: - schema_field = instance.__schema__.fields[model_field] - if schema_field.dump_only: - continue - value = getattr(instance, model_field) - nested_datamodel_name = getattr(schema_field, "datamodel_name", None) - if nested_datamodel_name: - comodel = instance.env[record._fields[model_field].comodel_name] - nested_instances = value if isinstance(value, list) else [value] - nested_start = comodel.browse([]) - value = env.datamodels[nested_datamodel_name]._many_to_recordset( - nested_instances, create=False, start=nested_start - ) - record[model_field] = instance._process_model_value(value, model_field) - values = {field_name: record[field_name] for field_name in model_fields} - values = record._convert_to_write(values) - if isinstance(record.id, models.NewId) and create: - record = record.create(values) - if isinstance(record.id, int) and write: - record.write(values) - recordset += record - return record - - @class_or_instancemethod - def to_recordset(self, *, instances=None, create=True, write=True, start=None): - if instances is None and isinstance(self, ModelSerializer): - instances = [self] - return self._many_to_recordset( - instances, create=create, write=write, start=start - ) + 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/serializers.py b/model_serializer/serializers.py index 2efa7315c..ec04150f4 100644 --- a/model_serializer/serializers.py +++ b/model_serializer/serializers.py @@ -15,3 +15,6 @@ def __init__(self, *args, **kwargs): class GenericMinimalSerializer(GenericAbstractSerializer): _name = "generic.minimal.serializer" _model_fields = ["id", "display_name"] + + def to_recordset(self): + return self.get_odoo_record() From 0917c366fc18c8e640affe9cb63ecd4541d6c084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Degrave?= Date: Thu, 15 Jul 2021 11:30:18 +0200 Subject: [PATCH 13/13] [IMP] model_serializer: allow to pass the 'many' param to dump --- model_serializer/core.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/model_serializer/core.py b/model_serializer/core.py index 768761208..4f0bc9364 100644 --- a/model_serializer/core.py +++ b/model_serializer/core.py @@ -23,6 +23,11 @@ class ModelSerializer(Datamodel, metaclass=MetaModelSerializer): _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