mirror of
https://github.com/odoo/runbot.git
synced 2025-03-27 13:25:47 +07:00
[IMP] runbot: implement public model mixin
The mixin provides tooling for graphql-ish requests with request and response schemas. The goal is to safely expose fields through a public api where public users may request data for a given schema similar to how graphql works.
This commit is contained in:
parent
9097aa4545
commit
f2e79007b9
@ -1,5 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import public_model_mixin
|
||||||
|
|
||||||
from . import batch
|
from . import batch
|
||||||
from . import branch
|
from . import branch
|
||||||
from . import build
|
from . import build
|
||||||
|
205
runbot/models/public_model_mixin.py
Normal file
205
runbot/models/public_model_mixin.py
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, Union, List
|
||||||
|
|
||||||
|
from odoo import models, api, fields, tools
|
||||||
|
|
||||||
|
|
||||||
|
ResponseSchema = Dict[str, Dict[str, Union[str, 'ResponseSchema']]]
|
||||||
|
RequestSchema = Dict[str, Union[None, 'RequestSchema']]
|
||||||
|
ModelData = Dict[str, Union[str, float, bool, int, 'ModelData']]
|
||||||
|
ResponseData = List[ModelData]
|
||||||
|
|
||||||
|
SUPPORTED_FIELD_TYPES = { # Perhaps this should be a list of class instead
|
||||||
|
'boolean', 'integer', 'float', 'char', 'text', 'html',
|
||||||
|
'date', 'datetime', 'selection', 'jsonb',
|
||||||
|
'many2one', 'one2many', 'many2many',
|
||||||
|
}
|
||||||
|
SCHEMA_MAX_DEPTH = 10
|
||||||
|
SCHEMA_METADATA_FIELDS = {'__type', '__relational', '__help'}
|
||||||
|
|
||||||
|
def _cleaned_schema(schema: RequestSchema) -> RequestSchema | None:
|
||||||
|
if schema:
|
||||||
|
schema = {
|
||||||
|
k: v for k, v in schema.items()
|
||||||
|
if k not in SCHEMA_METADATA_FIELDS
|
||||||
|
}
|
||||||
|
# Fallback to None if the schema is empty
|
||||||
|
return schema or None
|
||||||
|
|
||||||
|
class PublicModelMixin(models.AbstractModel):
|
||||||
|
_name = 'runbot.public.model.mixin'
|
||||||
|
_description = 'Mixin for publicly accessible data'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _valid_field_parameter(self, field: fields.Field, name: str):
|
||||||
|
if field.type in SUPPORTED_FIELD_TYPES:
|
||||||
|
return name in (
|
||||||
|
'public', # boolean, whether the field is readable through the public api
|
||||||
|
) or super()._valid_field_parameter(field, name)
|
||||||
|
return super()._valid_field_parameter(field, name)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_public_fields(self):
|
||||||
|
""" Returns a list of publicly readable fields. """
|
||||||
|
return [
|
||||||
|
field for field in self._fields.values()
|
||||||
|
if getattr(field, 'public', None) or field.name == 'id'
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_relation_cache_key(self, field: fields.Field):
|
||||||
|
""" Returns a cache key for a field, this is used to prevent infinite loops."""
|
||||||
|
if isinstance(field, fields.Many2one):
|
||||||
|
return f'{self._name}__{field.name}'
|
||||||
|
elif isinstance(field, fields.Many2many):
|
||||||
|
if not field.store:
|
||||||
|
return f'{self._name}__{field.name}'
|
||||||
|
return field.relation
|
||||||
|
elif isinstance(field, fields.One2many):
|
||||||
|
if not field.store: # is this valid?
|
||||||
|
return f'{self._name}__{field.name}'
|
||||||
|
CoModel: PublicModelMixin = self.env[field.comodel_name]
|
||||||
|
inverse_field = CoModel._fields[field.inverse_name]
|
||||||
|
return CoModel._get_relation_cache_key(inverse_field)
|
||||||
|
raise NotImplementedError('Unsupported field')
|
||||||
|
|
||||||
|
@tools.ormcache()
|
||||||
|
@api.model
|
||||||
|
def _get_public_schema(self) -> RequestSchema:
|
||||||
|
""" Returns an auto-generated schema according to public fields. """
|
||||||
|
# We want to prevent infinite loops so we need to track which relations
|
||||||
|
# have already been explored, this concerns many2one, many2many
|
||||||
|
def _visit_model(model: PublicModelMixin, visited_relations: set[str], depth = 0):
|
||||||
|
schema: RequestSchema = {}
|
||||||
|
for field in model._get_public_fields():
|
||||||
|
field_metadata = {
|
||||||
|
'__type': field.type,
|
||||||
|
'__relational': field.relational,
|
||||||
|
}
|
||||||
|
if field.help:
|
||||||
|
field_metadata['__help'] = field.help
|
||||||
|
if field.relational and \
|
||||||
|
PublicModelMixin._name in model.env[field.comodel_name]._inherit:
|
||||||
|
field_key = model._get_relation_cache_key(field)
|
||||||
|
if field_key in visited_relations or depth == SCHEMA_MAX_DEPTH:
|
||||||
|
continue
|
||||||
|
visited_relations.add(field_key)
|
||||||
|
CoModel: PublicModelMixin = model.env[field.comodel_name]
|
||||||
|
field_metadata.update(_visit_model(CoModel, {*visited_relations}, depth + 1))
|
||||||
|
schema[field.name] = field_metadata
|
||||||
|
return schema
|
||||||
|
|
||||||
|
return _visit_model(self, set())
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _verify_schema(self, schema: RequestSchema) -> bool:
|
||||||
|
"""
|
||||||
|
Verifies a given schema against the public schema.
|
||||||
|
|
||||||
|
This step also provides some validation of the schema, enough that the
|
||||||
|
schema can be safely used with `_read_schema` if the method does not
|
||||||
|
raise an exception.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema: The requested schema, it should be a dictionary with fields
|
||||||
|
as keys, and values should be None or a schema for a comodel if
|
||||||
|
applicable.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
If the schema matches the public schema this method returns True
|
||||||
|
otherwise False.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If a sub schema is given for a non relational field.
|
||||||
|
"""
|
||||||
|
public_schema: ResponseSchema = self._get_public_schema()
|
||||||
|
|
||||||
|
def _visit_schema(
|
||||||
|
model_schema: ResponseSchema,
|
||||||
|
request_schema: RequestSchema,
|
||||||
|
) -> bool:
|
||||||
|
for field, sub_schema in request_schema.items():
|
||||||
|
sub_schema = _cleaned_schema(sub_schema)
|
||||||
|
if field in SCHEMA_METADATA_FIELDS:
|
||||||
|
continue
|
||||||
|
if field not in model_schema:
|
||||||
|
return False
|
||||||
|
if sub_schema is None or not sub_schema.get('__relational', False):
|
||||||
|
continue
|
||||||
|
if not model_schema[field].get('__relational'):
|
||||||
|
raise ValueError(
|
||||||
|
'Invalid sub schema provided for non relational field'
|
||||||
|
)
|
||||||
|
if not _visit_schema(model_schema[field], sub_schema):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
return _visit_schema(public_schema, schema)
|
||||||
|
|
||||||
|
def _read_schema_read_field(self, field: fields.Field):
|
||||||
|
""" Adapts the type for a given field for schema reads. """
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
value = self[field.name]
|
||||||
|
if value is False and field.type != 'boolean':
|
||||||
|
value = None
|
||||||
|
if field.type == 'jsonb' and value:
|
||||||
|
value = value.dict
|
||||||
|
if field.type in ('date', 'datetime') and value:
|
||||||
|
value = value.isoformat()
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _read_schema(self, schema: RequestSchema) -> ResponseData:
|
||||||
|
""" Reads the current recordset according to the requested schema. """
|
||||||
|
assert isinstance(schema, dict), 'invalid schema'
|
||||||
|
|
||||||
|
# We want to prevent infinite loops so we need to track which relations
|
||||||
|
# have already been explored, this concerns many2one, many2many
|
||||||
|
def _read_record(
|
||||||
|
data: ModelData,
|
||||||
|
record: PublicModelMixin,
|
||||||
|
request_schema: RequestSchema,
|
||||||
|
visited_relations: set[str],
|
||||||
|
):
|
||||||
|
""" Reads the given record according to the schema inline"""
|
||||||
|
data['id'] = record.id
|
||||||
|
for field, sub_schema in request_schema.items():
|
||||||
|
sub_schema = _cleaned_schema(sub_schema)
|
||||||
|
if field in SCHEMA_METADATA_FIELDS: # it is authorized to pass the public schema directly
|
||||||
|
continue
|
||||||
|
_field = record._fields[field]
|
||||||
|
if not _field.relational:
|
||||||
|
data[field] = record._read_schema_read_field(_field)
|
||||||
|
else:
|
||||||
|
field_key = record._get_relation_cache_key(_field)
|
||||||
|
if field_key in visited_relations and not self.env.user.has_group('runbot.group_runbot_admin'):
|
||||||
|
raise ValueError('Tried to access a relation both ways.')
|
||||||
|
visited_relations.add(field_key)
|
||||||
|
if not isinstance(_field, fields._RelationalMulti):
|
||||||
|
co_record = record[field]
|
||||||
|
value = None
|
||||||
|
if co_record and not sub_schema:
|
||||||
|
value = co_record.id
|
||||||
|
elif co_record:
|
||||||
|
value = _read_record(
|
||||||
|
{},
|
||||||
|
co_record,
|
||||||
|
sub_schema,
|
||||||
|
{*visited_relations},
|
||||||
|
)
|
||||||
|
data[field] = value
|
||||||
|
else:
|
||||||
|
co_records = record[field]
|
||||||
|
if not sub_schema:
|
||||||
|
value = [co_record.id for co_record in co_records]
|
||||||
|
else:
|
||||||
|
value = [
|
||||||
|
_read_record({}, co_record, sub_schema, {*visited_relations})
|
||||||
|
for co_record in co_records
|
||||||
|
]
|
||||||
|
data[field] = value
|
||||||
|
return data
|
||||||
|
|
||||||
|
return [_read_record({}, record, schema, set()) for record in self]
|
1
runbot_test/__init__.py
Normal file
1
runbot_test/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import models as models
|
15
runbot_test/__manifest__.py
Normal file
15
runbot_test/__manifest__.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': "runbot test",
|
||||||
|
'summary': "Runbot test",
|
||||||
|
'description': "Runbot test",
|
||||||
|
'author': "Odoo SA",
|
||||||
|
'website': "http://runbot.odoo.com",
|
||||||
|
'category': 'Website',
|
||||||
|
'version': '1.0',
|
||||||
|
'depends': ['runbot'],
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
],
|
||||||
|
}
|
52
runbot_test/models.py
Normal file
52
runbot_test/models.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
from odoo import models, api, fields
|
||||||
|
from odoo.addons.runbot.fields import JsonDictField
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelParent(models.Model):
|
||||||
|
_name = 'runbot.test.model.parent'
|
||||||
|
_inherit = ['runbot.public.model.mixin']
|
||||||
|
_description = 'parent'
|
||||||
|
|
||||||
|
private_field = fields.Boolean(public=False)
|
||||||
|
|
||||||
|
field_bool = fields.Boolean(public=True)
|
||||||
|
field_integer = fields.Integer(public=True)
|
||||||
|
field_float = fields.Float(public=True)
|
||||||
|
field_char = fields.Char(public=True)
|
||||||
|
field_text = fields.Text(public=True)
|
||||||
|
field_html = fields.Html(public=True)
|
||||||
|
field_date = fields.Date(public=True)
|
||||||
|
field_datetime = fields.Datetime(public=True)
|
||||||
|
field_selection = fields.Selection(selection=[('a', 'foo'), ('b', 'bar')], public=True)
|
||||||
|
field_json = JsonDictField(public=True)
|
||||||
|
|
||||||
|
field_many2one = fields.Many2one('runbot.test.model.parent', public=True)
|
||||||
|
field_one2many = fields.One2many('runbot.test.model.child', 'parent_id', public=True)
|
||||||
|
field_many2many = fields.Many2many(
|
||||||
|
'runbot.test.model.parent', relation='test_model_relation', public=True,
|
||||||
|
column1='col_1', column2='col_2',
|
||||||
|
)
|
||||||
|
|
||||||
|
field_one2many_computed = fields.One2many('runbot.test.model.child', compute='_compute_one2many', public=True)
|
||||||
|
field_many2many_computed = fields.Many2many('runbot.test.model.parent', compute='_compute_many2many', public=True)
|
||||||
|
|
||||||
|
@api.depends()
|
||||||
|
def _compute_one2many(self):
|
||||||
|
self.field_one2many_computed = self.env['runbot.test.model.child'].search([])
|
||||||
|
|
||||||
|
@api.depends()
|
||||||
|
def _compute_many2many(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.field_many2many_computed = self.env['runbot.test.model.parent'].search([
|
||||||
|
('id', '!=', rec.id),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelChild(models.Model):
|
||||||
|
_name = 'runbot.test.model.child'
|
||||||
|
_inherit = ['runbot.public.model.mixin']
|
||||||
|
_description = 'child'
|
||||||
|
|
||||||
|
parent_id = fields.Many2one('runbot.test.model.parent', required=True, public=True)
|
||||||
|
|
||||||
|
data = fields.Integer(public=True)
|
6
runbot_test/security/ir.model.access.csv
Normal file
6
runbot_test/security/ir.model.access.csv
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
runbot_test.access_runbot_test_model_parent,access_runbot_test_model_parent,runbot_test.model_runbot_test_model_parent,base.group_user,1,0,0,0
|
||||||
|
runbot_test.access_runbot_test_model_child,access_runbot_test_model_child,runbot_test.model_runbot_test_model_child,base.group_user,1,0,0,0
|
||||||
|
|
||||||
|
runbot_test.access_runbot_test_model_parent_admin,access_runbot_test_model_parent_admin,runbot_test.model_runbot_test_model_parent,runbot.group_runbot_admin,1,1,1,1
|
||||||
|
runbot_test.access_runbot_test_model_child_admin,access_runbot_test_model_child_admin,runbot_test.model_runbot_test_model_child,runbot.group_runbot_admin,1,1,1,1
|
|
1
runbot_test/tests/__init__.py
Normal file
1
runbot_test/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import test_schema as test_schema
|
277
runbot_test/tests/test_schema.py
Normal file
277
runbot_test/tests/test_schema.py
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
from odoo import fields
|
||||||
|
from odoo.tests.common import TransactionCase, tagged, new_test_user
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install')
|
||||||
|
class TestSchema(TransactionCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
|
||||||
|
cls.Parent = cls.env['runbot.test.model.parent']
|
||||||
|
cls.Child = cls.env['runbot.test.model.child']
|
||||||
|
cls.user_basic = new_test_user(
|
||||||
|
cls.env, 'basic',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parent_schema(self):
|
||||||
|
schema = self.Parent._get_public_schema()
|
||||||
|
self.assertNotIn('private_field', schema)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
schema['field_bool'],
|
||||||
|
{
|
||||||
|
'__type': 'boolean',
|
||||||
|
'__relational': False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('field_many2one', schema)
|
||||||
|
sub_schema = schema['field_many2one']
|
||||||
|
self.assertIn('field_many2many', schema)
|
||||||
|
self.assertIn('field_many2many', sub_schema)
|
||||||
|
|
||||||
|
self.assertTrue(self.Parent._verify_schema(schema))
|
||||||
|
|
||||||
|
def test_child_schema(self):
|
||||||
|
schema = self.Child._get_public_schema()
|
||||||
|
|
||||||
|
self.assertIn('parent_id', schema)
|
||||||
|
self.assertIn('data', schema)
|
||||||
|
|
||||||
|
sub_schema = schema['parent_id']
|
||||||
|
# The reverse relation should not be part of the sub schema, as we already
|
||||||
|
# traversed that relation
|
||||||
|
self.assertNotIn('field_one2many', sub_schema)
|
||||||
|
|
||||||
|
self.assertTrue(self.Child._verify_schema(schema))
|
||||||
|
|
||||||
|
def test_parent_read_basic(self):
|
||||||
|
today = fields.Date.today()
|
||||||
|
now = fields.Datetime.now()
|
||||||
|
parent = self.Parent.create({
|
||||||
|
'field_bool': True,
|
||||||
|
'field_date': today,
|
||||||
|
'field_datetime': now,
|
||||||
|
'field_json': {'test': 1}
|
||||||
|
})
|
||||||
|
self.assertEqual(
|
||||||
|
parent._read_schema({'field_bool': None, 'field_date': None, 'field_datetime': None, 'field_json': None}),
|
||||||
|
[{
|
||||||
|
'id': parent.id,
|
||||||
|
'field_bool': True,
|
||||||
|
'field_date': today.isoformat(),
|
||||||
|
'field_datetime': now.isoformat(),
|
||||||
|
'field_json': {'test': 1}
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parent_read_m2o(self):
|
||||||
|
parent_1 = self.Parent.create({'field_integer': 1})
|
||||||
|
parent_2 = self.Parent.create({'field_integer': 2, 'field_many2one': parent_1.id})
|
||||||
|
|
||||||
|
# Read without sub schema
|
||||||
|
self.assertEqual(
|
||||||
|
parent_1._read_schema({'field_many2one': None}),
|
||||||
|
[{'id': parent_1.id, 'field_many2one': None}]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
parent_2._read_schema({'field_many2one': None}),
|
||||||
|
[{'id': parent_2.id, 'field_many2one': parent_1.id}]
|
||||||
|
)
|
||||||
|
# Read with sub schema
|
||||||
|
self.assertEqual(
|
||||||
|
parent_1._read_schema({'field_many2one': {'field_integer': None}}),
|
||||||
|
[{'id': parent_1.id, 'field_many2one': None}]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
parent_2._read_schema({'field_many2one': {'field_integer': None}}),
|
||||||
|
[{'id': parent_2.id, 'field_many2one': {'id': parent_1.id, 'field_integer': parent_1.field_integer}}]
|
||||||
|
)
|
||||||
|
both = parent_1 | parent_2
|
||||||
|
# Read both with sub schema
|
||||||
|
self.assertEqual(
|
||||||
|
both._read_schema({'field_integer': None, 'field_many2one': {'field_integer': None}}),
|
||||||
|
[
|
||||||
|
{'id': parent_1.id, 'field_integer': parent_1.field_integer, 'field_many2one': None},
|
||||||
|
{'id': parent_2.id, 'field_integer': parent_2.field_integer, 'field_many2one': {
|
||||||
|
'id': parent_1.id, 'field_integer': parent_1.field_integer,
|
||||||
|
}},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parent_read_one2many(self):
|
||||||
|
parent = self.Parent.create({
|
||||||
|
'field_float': 13.37,
|
||||||
|
'field_one2many': [
|
||||||
|
(0, 0, {'data': 1}),
|
||||||
|
(0, 0, {'data': 2}),
|
||||||
|
(0, 0, {'data': 3}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
parent_no_o2m = self.Parent.create({
|
||||||
|
'field_float': 13.37,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Basic read
|
||||||
|
self.assertEqual(
|
||||||
|
parent._read_schema({
|
||||||
|
'field_float': None,
|
||||||
|
'field_one2many': {
|
||||||
|
'data': None
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[{
|
||||||
|
'id': parent.id,
|
||||||
|
'field_float': 13.37,
|
||||||
|
'field_one2many': [
|
||||||
|
{'id': parent.field_one2many[0].id, 'data': 1},
|
||||||
|
{'id': parent.field_one2many[1].id, 'data': 2},
|
||||||
|
{'id': parent.field_one2many[2].id, 'data': 3},
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
parent_no_o2m._read_schema({
|
||||||
|
'field_float': None,
|
||||||
|
'field_one2many': {
|
||||||
|
'data': None,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[{
|
||||||
|
'id': parent_no_o2m.id,
|
||||||
|
'field_float': 13.37,
|
||||||
|
'field_one2many': [],
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reading parent_id through field_one2many_computed is allowed since relationship is not known
|
||||||
|
self.env.invalidate_all()
|
||||||
|
schema = {
|
||||||
|
'field_float': None,
|
||||||
|
'field_one2many': {
|
||||||
|
'data': None,
|
||||||
|
'parent_id': {
|
||||||
|
'field_float': None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
parent._read_schema(schema),
|
||||||
|
[{
|
||||||
|
'id': parent.id,
|
||||||
|
'field_float': 13.37,
|
||||||
|
'field_one2many': [
|
||||||
|
{'id': parent.field_one2many[0].id, 'data': 1, 'parent_id': {'id': parent.id, 'field_float': 13.37}},
|
||||||
|
{'id': parent.field_one2many[1].id, 'data': 2, 'parent_id': {'id': parent.id, 'field_float': 13.37}},
|
||||||
|
{'id': parent.field_one2many[2].id, 'data': 3, 'parent_id': {'id': parent.id, 'field_float': 13.37}},
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
# It should not work with basic user
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
parent.with_user(self.user_basic)._read_schema(schema)
|
||||||
|
# It should however work with the computed version
|
||||||
|
schema['field_one2many_computed'] = schema['field_one2many']
|
||||||
|
schema.pop('field_one2many')
|
||||||
|
self.assertEqual(
|
||||||
|
parent.with_user(self.user_basic)._read_schema(schema),
|
||||||
|
[{
|
||||||
|
'id': parent.id,
|
||||||
|
'field_float': 13.37,
|
||||||
|
'field_one2many_computed': [
|
||||||
|
{'id': parent.field_one2many[0].id, 'data': 1, 'parent_id': {'id': parent.id, 'field_float': 13.37}},
|
||||||
|
{'id': parent.field_one2many[1].id, 'data': 2, 'parent_id': {'id': parent.id, 'field_float': 13.37}},
|
||||||
|
{'id': parent.field_one2many[2].id, 'data': 3, 'parent_id': {'id': parent.id, 'field_float': 13.37}},
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parent_read_m2m(self):
|
||||||
|
parent = self.Parent.create({
|
||||||
|
'field_integer': 1,
|
||||||
|
'field_many2many': [
|
||||||
|
(0, 0, {'field_integer': 2}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
other_parent = parent.field_many2many
|
||||||
|
self.assertEqual(
|
||||||
|
parent._read_schema({'field_integer': None, 'field_many2many': None}),
|
||||||
|
[
|
||||||
|
{'id': parent.id, 'field_many2many': list(parent.field_many2many.ids), 'field_integer': 1},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.env.invalidate_all()
|
||||||
|
schema = {
|
||||||
|
'field_many2many_computed': {
|
||||||
|
'field_many2many_computed': {
|
||||||
|
'field_integer': None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
parent._read_schema(schema),
|
||||||
|
[
|
||||||
|
{'id': parent.id, 'field_many2many_computed': [
|
||||||
|
{'id': other_parent.id, 'field_many2many_computed': [
|
||||||
|
{'id': parent.id, 'field_integer': 1}
|
||||||
|
]}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.assertEqual(
|
||||||
|
parent.with_user(self.user_basic)._read_schema(schema),
|
||||||
|
[
|
||||||
|
{'id': parent.id, 'field_many2many_computed': [
|
||||||
|
{'id': other_parent.id, 'field_many2many_computed': [
|
||||||
|
{'id': parent.id, 'field_integer': 1}
|
||||||
|
]}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parent_read_cyclic_parent(self):
|
||||||
|
parent_1 = self.Parent.create({
|
||||||
|
'field_integer': 1,
|
||||||
|
})
|
||||||
|
parent_2 = self.Parent.create({
|
||||||
|
'field_integer': 2,
|
||||||
|
'field_many2one': parent_1.id,
|
||||||
|
})
|
||||||
|
parent_1.field_many2one = parent_2
|
||||||
|
self.env.invalidate_all()
|
||||||
|
both = (parent_1 | parent_2)
|
||||||
|
self.assertEqual(
|
||||||
|
both._read_schema({'field_many2one': None}),
|
||||||
|
[
|
||||||
|
{'id': parent_1.id, 'field_many2one': parent_2.id},
|
||||||
|
{'id': parent_2.id, 'field_many2one': parent_1.id},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
both.with_user(self.user_basic)._read_schema({'field_many2one': None}),
|
||||||
|
[
|
||||||
|
{'id': parent_1.id, 'field_many2one': parent_2.id},
|
||||||
|
{'id': parent_2.id, 'field_many2one': parent_1.id},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
schema = {'field_many2one': {'field_many2one': None}}
|
||||||
|
self.assertEqual(
|
||||||
|
both._read_schema(schema),
|
||||||
|
[
|
||||||
|
{'id': parent_1.id, 'field_many2one': {'id': parent_2.id, 'field_many2one': parent_1.id}},
|
||||||
|
{'id': parent_2.id, 'field_many2one': {'id': parent_1.id, 'field_many2one': parent_2.id}},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.assertEqual(
|
||||||
|
both.with_user(self.user_basic)._read_schema(schema),
|
||||||
|
[
|
||||||
|
{'id': parent_1.id, 'field_many2one': {'id': parent_2.id, 'field_many2one': parent_1.id}},
|
||||||
|
{'id': parent_2.id, 'field_many2one': {'id': parent_1.id, 'field_many2one': parent_2.id}},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user