From b21e255952cc898ae58d9dc487e6dacef36d0ea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Degrave?= Date: Thu, 2 Sep 2021 15:53:13 +0200 Subject: [PATCH] [14.0][FIX] base_rest_datamodel: modify validation of responses to allow `dump_only` fields Forward port of PR https://github.com/OCA/rest-framework/pull/169/files --- base_rest_datamodel/restapi.py | 4 +- base_rest_datamodel/tests/__init__.py | 1 + base_rest_datamodel/tests/test_response.py | 76 ++++++++++++++++++++++ base_rest_demo/datamodels/partner_info.py | 2 +- datamodel/core.py | 30 +++++++++ 5 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 base_rest_datamodel/tests/__init__.py create mode 100644 base_rest_datamodel/tests/test_response.py diff --git a/base_rest_datamodel/restapi.py b/base_rest_datamodel/restapi.py index dc11150f4..449949075 100644 --- a/base_rest_datamodel/restapi.py +++ b/base_rest_datamodel/restapi.py @@ -37,7 +37,9 @@ def to_response(self, service, result): json = [i.dump() for i in result] else: json = result.dump() - errors = ModelClass.validate(json, many=self._is_list) + errors = ModelClass.validate( + json, many=self._is_list, unknown=marshmallow.EXCLUDE + ) if errors: raise SystemError(_("Invalid Response %s") % errors) return json diff --git a/base_rest_datamodel/tests/__init__.py b/base_rest_datamodel/tests/__init__.py new file mode 100644 index 000000000..d5006168c --- /dev/null +++ b/base_rest_datamodel/tests/__init__.py @@ -0,0 +1 @@ +from . import test_response diff --git a/base_rest_datamodel/tests/test_response.py b/base_rest_datamodel/tests/test_response.py new file mode 100644 index 000000000..0424324f5 --- /dev/null +++ b/base_rest_datamodel/tests/test_response.py @@ -0,0 +1,76 @@ +# Copyright 2021 Wakari SRL +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import marshmallow +import mock + +from odoo.addons.base_rest_datamodel import restapi +from odoo.addons.datamodel import fields +from odoo.addons.datamodel.core import Datamodel +from odoo.addons.datamodel.tests import common + + +class TestDataModel(common.DatamodelRegistryCase): + def _to_response(self, instance): + restapi_datamodel = restapi.Datamodel(instance._name) + mock_service = mock.Mock() + mock_service.env = self.env + return restapi_datamodel.to_response(mock_service, instance) + + def test_to_response(self): + class Datamodel1(Datamodel): + _name = "datamodel1" + + name = fields.String(required=True, allow_none=False) + + Datamodel1._build_datamodel(self.datamodel_registry) + instance = self.env.datamodels["datamodel1"](name="Instance 1") + res = self._to_response(instance) + self.assertEqual(res["name"], instance.name) + + def test_to_response_dump_only(self): + class Datamodel2(Datamodel): + _name = "datamodel2" + + name = fields.String(required=True, allow_none=False, dump_only=True) + + Datamodel2._build_datamodel(self.datamodel_registry) + schema = self.env.datamodels["datamodel2"].get_schema() + self.assertEqual(schema.unknown, "raise") + msg = r"{'name': \['Unknown field.'\]}" + with self.assertRaisesRegex(marshmallow.exceptions.ValidationError, msg): + # confirmation that "name" cannot be loaded + self.env.datamodels["datamodel2"].load({"name": "Failure"}) + instance = self.env.datamodels["datamodel2"](name="Instance 2") + res = self._to_response(instance) + self.assertEqual(res["name"], instance.name) + # schema 'unknown' is back to "raise" + self.assertEqual(schema.unknown, "raise") + + def test_to_response_dump_only_nested(self): + class Datamodel3(Datamodel): + _name = "datamodel3" + + child = fields.NestedModel("nested_datamodel") + + class NestedDatamodel(Datamodel): + _name = "nested_datamodel" + + name = fields.String(required=True, allow_none=False, dump_only=True) + + NestedDatamodel._build_datamodel(self.datamodel_registry) + Datamodel3._build_datamodel(self.datamodel_registry) + for datamodel_name in ("datamodel3", "nested_datamodel"): + schema = self.env.datamodels[datamodel_name].get_schema() + self.assertEqual(schema.unknown, "raise") + msg = r"{'name': \['Unknown field.'\]}" + with self.assertRaisesRegex(marshmallow.exceptions.ValidationError, msg): + # confirmation that child "name" cannot be loaded + self.env.datamodels["datamodel3"].load({"child": {"name": "Failure"}}) + child_instance = NestedDatamodel(name="Child Instance") + instance = self.env.datamodels["datamodel3"](child=child_instance) + res = self._to_response(instance) + self.assertEqual(res["child"]["name"], child_instance.name) + for datamodel_name in ("datamodel3", "nested_datamodel"): + schema = self.env.datamodels[datamodel_name].get_schema() + # schema 'unknown' is back to "raise" + self.assertEqual(schema.unknown, "raise") diff --git a/base_rest_demo/datamodels/partner_info.py b/base_rest_demo/datamodels/partner_info.py index f8f727851..991596298 100644 --- a/base_rest_demo/datamodels/partner_info.py +++ b/base_rest_demo/datamodels/partner_info.py @@ -18,4 +18,4 @@ class PartnerInfo(Datamodel): phone = fields.String(required=False, allow_none=True) state = NestedModel("state.info") country = NestedModel("country.info") - is_componay = fields.Boolean(required=False, allow_none=False) + is_company = fields.Boolean(required=False, allow_none=False) diff --git a/datamodel/core.py b/datamodel/core.py index 2665341f2..ee6c5a25e 100644 --- a/datamodel/core.py +++ b/datamodel/core.py @@ -5,6 +5,9 @@ import functools import logging from collections import OrderedDict, defaultdict +from contextlib import ExitStack + +from marshmallow import INCLUDE from odoo.api import Environment from odoo.tools import LastOrderedSet, OrderedSet @@ -41,6 +44,14 @@ def _get_addon_name(full_name): return addon_name +def _get_nested_schemas(schema): + res = [schema] + for field in schema.fields.values(): + if getattr(field, "schema", None): + res += _get_nested_schemas(field.schema) + return res + + class DatamodelDatabases(dict): """ Holds a registry of datamodels for each database """ @@ -131,6 +142,13 @@ def __init__(self, name, bases, attrs): self._modules_datamodels[self._module].append(self) + def __call__(self, *args, **kwargs): + """Allow to set any field (including 'dump_only') at instantiation + This is not an issue thanks to cleanup during (de)serialization + """ + kwargs["unknown"] = kwargs.get("unknown", INCLUDE) + return super().__call__(*args, **kwargs) + class Datamodel(MarshmallowModel, metaclass=MetaDatamodel): """Main Datamodel Model @@ -207,6 +225,18 @@ def get_schema(cls, **kwargs): """ return cls.__get_schema_class__(**kwargs) + @classmethod + def validate(cls, data, context=None, many=None, partial=None, unknown=None): + schema = cls.__get_schema_class__( + context=context, partial=partial, unknown=unknown + ) + all_schemas = _get_nested_schemas(schema) + with ExitStack() as stack: + # propagate 'unknown' to each nested schema during validate + for nested_schema in all_schemas: + stack.enter_context(cls.propagate_unknwown(nested_schema, unknown)) + return schema.validate(data, many=many, partial=partial) + @classmethod def _build_datamodel(cls, registry): """Instantiate a given Datamodel in the datamodels registry.