commit
0f6047bead
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"pull_requests": {
|
||||||
|
"OCA/server-tools#2744": "too many conflicts, will run pre-commit afterwards",
|
||||||
|
"OCA/server-tools#2668": "Ported manually bc of too many conflicts"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Copyright 2022 Camptocamp SA (http://www.camptocamp.com)
|
||||||
|
# Simone Orsi <simahawk@gmail.com>
|
||||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
|
||||||
|
|
||||||
|
|
||||||
|
class SwallableException(Exception):
|
||||||
|
"""An exception that can be safely skipped."""
|
|
@ -109,7 +109,8 @@ class IrExports(models.Model):
|
||||||
if line.target:
|
if line.target:
|
||||||
names = line.target.split("/")
|
names = line.target.split("/")
|
||||||
function = line.instance_method_name
|
function = line.instance_method_name
|
||||||
options = {"resolver": line.resolver_id, "function": function}
|
# resolver must be passed as ID to avoid cache issues
|
||||||
|
options = {"resolver": line.resolver_id.id, "function": function}
|
||||||
update_dict(dict_parser, names, options)
|
update_dict(dict_parser, names, options)
|
||||||
lang_parsers[lang] = convert_dict(dict_parser)
|
lang_parsers[lang] = convert_dict(dict_parser)
|
||||||
if list(lang_parsers.keys()) == [False]:
|
if list(lang_parsers.keys()) == [False]:
|
||||||
|
@ -117,7 +118,7 @@ class IrExports(models.Model):
|
||||||
else:
|
else:
|
||||||
parser["langs"] = lang_parsers
|
parser["langs"] = lang_parsers
|
||||||
if self.global_resolver_id:
|
if self.global_resolver_id:
|
||||||
parser["resolver"] = self.global_resolver_id
|
parser["resolver"] = self.global_resolver_id.id
|
||||||
if self.language_agnostic:
|
if self.language_agnostic:
|
||||||
parser["language_agnostic"] = self.language_agnostic
|
parser["language_agnostic"] = self.language_agnostic
|
||||||
return parser
|
return parser
|
||||||
|
|
|
@ -6,13 +6,18 @@ from odoo.tools.safe_eval import safe_eval
|
||||||
|
|
||||||
help_message = [
|
help_message = [
|
||||||
"Compute the result from 'value' by setting the variable 'result'.",
|
"Compute the result from 'value' by setting the variable 'result'.",
|
||||||
"For fields resolvers:",
|
"\n" "For fields resolvers:",
|
||||||
|
":param record: the record",
|
||||||
":param name: name of the field",
|
":param name: name of the field",
|
||||||
":param value: value of the field",
|
":param value: value of the field",
|
||||||
":param field_type: type of the field",
|
":param field_type: type of the field",
|
||||||
"For global resolvers:",
|
"\n" "For global resolvers:",
|
||||||
":param value: JSON dict",
|
":param value: JSON dict",
|
||||||
":param record: the record",
|
":param record: the record",
|
||||||
|
"\n"
|
||||||
|
"In both types, you can override the final json key."
|
||||||
|
"\nTo achieve this, simply return a dict like: "
|
||||||
|
"\n{'result': {'_value': $value, '_json_key': $new_json_key}}",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -42,6 +47,7 @@ class FieldResolver(models.Model):
|
||||||
else: # param is a field
|
else: # param is a field
|
||||||
for record in records:
|
for record in records:
|
||||||
values = {
|
values = {
|
||||||
|
"record": record,
|
||||||
"value": record[param.name],
|
"value": record[param.name],
|
||||||
"name": param.name,
|
"name": param.name,
|
||||||
"field_type": param.type,
|
"field_type": param.type,
|
||||||
|
|
|
@ -12,6 +12,7 @@ from odoo.exceptions import UserError
|
||||||
from odoo.tools.misc import format_duration
|
from odoo.tools.misc import format_duration
|
||||||
from odoo.tools.translate import _
|
from odoo.tools.translate import _
|
||||||
|
|
||||||
|
from ..exceptions import SwallableException
|
||||||
from .utils import convert_simple_to_full_parser
|
from .utils import convert_simple_to_full_parser
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
@ -71,28 +72,73 @@ class Base(models.AbstractModel):
|
||||||
def _jsonify_record(self, parser, rec, root):
|
def _jsonify_record(self, parser, rec, root):
|
||||||
"""JSONify one record (rec). Private function called by jsonify."""
|
"""JSONify one record (rec). Private function called by jsonify."""
|
||||||
strict = self.env.context.get("jsonify_record_strict", False)
|
strict = self.env.context.get("jsonify_record_strict", False)
|
||||||
for field_key in parser:
|
for field in parser:
|
||||||
field_dict, subparser = rec.__parse_field(field_key)
|
field_dict, subparser = rec.__parse_field(field)
|
||||||
field_name = field_dict["name"]
|
|
||||||
field = rec._fields.get(field_name)
|
|
||||||
function = field_dict.get("function")
|
function = field_dict.get("function")
|
||||||
if not field and not function:
|
try:
|
||||||
|
self._jsonify_record_validate_field(rec, field_dict, strict)
|
||||||
|
except SwallableException:
|
||||||
|
if not function:
|
||||||
|
continue
|
||||||
|
json_key = field_dict.get("target", field_dict["name"])
|
||||||
|
if function:
|
||||||
|
try:
|
||||||
|
value = self._jsonify_record_handle_function(
|
||||||
|
rec, field_dict, strict
|
||||||
|
)
|
||||||
|
except SwallableException:
|
||||||
|
continue
|
||||||
|
elif subparser:
|
||||||
|
try:
|
||||||
|
value = self._jsonify_record_handle_subparser(
|
||||||
|
rec, field_dict, strict, subparser
|
||||||
|
)
|
||||||
|
except SwallableException:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
field = rec._fields[field_dict["name"]]
|
||||||
|
value = rec._jsonify_value(field, rec[field.name])
|
||||||
|
resolver = field_dict.get("resolver")
|
||||||
|
if resolver:
|
||||||
|
if isinstance(resolver, int):
|
||||||
|
# cached versions of the parser are stored as integer
|
||||||
|
resolver = self.env["ir.exports.resolver"].browse(resolver)
|
||||||
|
value, json_key = self._jsonify_record_handle_resolver(
|
||||||
|
rec, field, resolver, json_key
|
||||||
|
)
|
||||||
|
# whatever json value we have found in subparser or not ass a sister key
|
||||||
|
# on the same level _fieldname_{json_key}
|
||||||
|
if rec.env.context.get("with_fieldname"):
|
||||||
|
json_key_fieldname = "_fieldname_" + json_key
|
||||||
|
# check if we are in a subparser has already the fieldname sister keys
|
||||||
|
fieldname_value = rec._fields[field_dict["name"]].string
|
||||||
|
self._add_json_key(root, json_key_fieldname, fieldname_value)
|
||||||
|
self._add_json_key(root, json_key, value)
|
||||||
|
return root
|
||||||
|
|
||||||
|
def _jsonify_record_validate_field(self, rec, field_dict, strict):
|
||||||
|
field_name = field_dict["name"]
|
||||||
|
if field_name not in rec._fields:
|
||||||
if strict:
|
if strict:
|
||||||
# let it fail
|
# let it fail
|
||||||
rec._fields[field_name] # pylint: disable=pointless-statement
|
rec._fields[field_name] # pylint: disable=pointless-statement
|
||||||
|
else:
|
||||||
if not tools.config["test_enable"]:
|
if not tools.config["test_enable"]:
|
||||||
# If running live, log proper error
|
# If running live, log proper error
|
||||||
# so that techies can track it down
|
# so that techies can track it down
|
||||||
_logger.error(
|
_logger.warning(
|
||||||
"%(model)s.%(fname)s not available",
|
"%(model)s.%(fname)s not available",
|
||||||
{"model": self._name, "fname": field_name},
|
{"model": self._name, "fname": field_name},
|
||||||
)
|
)
|
||||||
continue
|
raise SwallableException()
|
||||||
json_key = field_dict.get("target", field_name)
|
return True
|
||||||
if function:
|
|
||||||
|
def _jsonify_record_handle_function(self, rec, field_dict, strict):
|
||||||
|
field_name = field_dict["name"]
|
||||||
|
function = field_dict["function"]
|
||||||
try:
|
try:
|
||||||
value = self._function_value(rec, function, field_name)
|
return self._function_value(rec, function, field_name)
|
||||||
except UserError:
|
except UserError as err:
|
||||||
if strict:
|
if strict:
|
||||||
raise
|
raise
|
||||||
if not tools.config["test_enable"]:
|
if not tools.config["test_enable"]:
|
||||||
|
@ -100,8 +146,11 @@ class Base(models.AbstractModel):
|
||||||
"%(model)s.%(func)s not available",
|
"%(model)s.%(func)s not available",
|
||||||
{"model": self._name, "func": str(function)},
|
{"model": self._name, "func": str(function)},
|
||||||
)
|
)
|
||||||
continue
|
raise SwallableException() from err
|
||||||
elif subparser:
|
|
||||||
|
def _jsonify_record_handle_subparser(self, rec, field_dict, strict, subparser):
|
||||||
|
field_name = field_dict["name"]
|
||||||
|
field = rec._fields[field_name]
|
||||||
if not (field.relational or field.type == "reference"):
|
if not (field.relational or field.type == "reference"):
|
||||||
if strict:
|
if strict:
|
||||||
self._jsonify_bad_parser_error(field_name)
|
self._jsonify_bad_parser_error(field_name)
|
||||||
|
@ -110,21 +159,25 @@ class Base(models.AbstractModel):
|
||||||
"%(model)s.%(fname)s not relational",
|
"%(model)s.%(fname)s not relational",
|
||||||
{"model": self._name, "fname": field_name},
|
{"model": self._name, "fname": field_name},
|
||||||
)
|
)
|
||||||
continue
|
raise SwallableException()
|
||||||
value = [
|
value = [self._jsonify_record(subparser, r, {}) for r in rec[field_name]]
|
||||||
self._jsonify_record(subparser, r, {}) for r in rec[field_name]
|
|
||||||
]
|
|
||||||
if field.type in ("many2one", "reference"):
|
if field.type in ("many2one", "reference"):
|
||||||
value = value[0] if value else None
|
value = value[0] if value else None
|
||||||
else:
|
|
||||||
resolver = field_dict.get("resolver")
|
return value
|
||||||
|
|
||||||
|
def _jsonify_record_handle_resolver(self, rec, field, resolver, json_key):
|
||||||
value = rec._jsonify_value(field, rec[field.name])
|
value = rec._jsonify_value(field, rec[field.name])
|
||||||
value = resolver.resolve(field, rec)[0] if resolver else value
|
value = resolver.resolve(field, rec)[0] if resolver else value
|
||||||
|
if isinstance(value, dict) and "_json_key" in value and "_value" in value:
|
||||||
|
# Allow override of json_key.
|
||||||
|
# In this case,
|
||||||
|
# the final value must be encapsulated into _value key
|
||||||
|
value, json_key = value["_value"], value["_json_key"]
|
||||||
|
return value, json_key
|
||||||
|
|
||||||
self._add_json_key(root, json_key, value)
|
def jsonify(self, parser, one=False, with_fieldname=False):
|
||||||
return root
|
|
||||||
|
|
||||||
def jsonify(self, parser, one=False):
|
|
||||||
"""Convert the record according to the given parser.
|
"""Convert the record according to the given parser.
|
||||||
|
|
||||||
Example of (simple) parser:
|
Example of (simple) parser:
|
||||||
|
@ -156,12 +209,19 @@ class Base(models.AbstractModel):
|
||||||
if isinstance(parser, list):
|
if isinstance(parser, list):
|
||||||
parser = convert_simple_to_full_parser(parser)
|
parser = convert_simple_to_full_parser(parser)
|
||||||
resolver = parser.get("resolver")
|
resolver = parser.get("resolver")
|
||||||
|
if isinstance(resolver, int):
|
||||||
|
# cached versions of the parser are stored as integer
|
||||||
|
resolver = self.env["ir.exports.resolver"].browse(resolver)
|
||||||
results = [{} for record in self]
|
results = [{} for record in self]
|
||||||
parsers = {False: parser["fields"]} if "fields" in parser else parser["langs"]
|
parsers = {False: parser["fields"]} if "fields" in parser else parser["langs"]
|
||||||
for lang in parsers:
|
for lang in parsers:
|
||||||
translate = lang or parser.get("language_agnostic")
|
translate = lang or parser.get("language_agnostic")
|
||||||
records = self.with_context(lang=lang) if translate else self
|
new_ctx = {}
|
||||||
|
if translate:
|
||||||
|
new_ctx["lang"] = lang
|
||||||
|
if with_fieldname:
|
||||||
|
new_ctx["with_fieldname"] = True
|
||||||
|
records = self.with_context(**new_ctx) if new_ctx else self
|
||||||
for record, json in zip(records, results):
|
for record, json in zip(records, results):
|
||||||
self._jsonify_record(parsers[lang], record, json)
|
self._jsonify_record(parsers[lang], record, json)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
with_fieldname parameter
|
||||||
|
==========================
|
||||||
|
|
||||||
|
The with_fieldname option of jsonify() method, when true, will inject on
|
||||||
|
the same level of the data "_fieldname_$field" keys that will
|
||||||
|
contain the field name, in the language of the current user.
|
||||||
|
|
||||||
|
|
||||||
|
Examples of with_fieldname usage:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
# example 1
|
||||||
|
parser = [('name')]
|
||||||
|
a.jsonify(parser=parser)
|
||||||
|
[{'name': 'SO3996'}]
|
||||||
|
>>> a.jsonify(parser=parser, with_fieldname=False)
|
||||||
|
[{'name': 'SO3996'}]
|
||||||
|
>>> a.jsonify(parser=parser, with_fieldname=True)
|
||||||
|
[{'fieldname_name': 'Order Reference', 'name': 'SO3996'}}]
|
||||||
|
|
||||||
|
|
||||||
|
# example 2 - with a subparser-
|
||||||
|
parser=['name', 'create_date', ('order_line', ['id' , 'product_uom', 'is_expense'])]
|
||||||
|
>>> a.jsonify(parser=parser, with_fieldname=False)
|
||||||
|
[{'name': 'SO3996', 'create_date': '2015-06-02T12:18:26.279909+00:00', 'order_line': [{'id': 16649, 'product_uom': 'stuks', 'is_expense': False}, {'id': 16651, 'product_uom': 'stuks', 'is_expense': False}, {'id': 16650, 'product_uom': 'stuks', 'is_expense': False}]}]
|
||||||
|
>>> a.jsonify(parser=parser, with_fieldname=True)
|
||||||
|
[{'fieldname_name': 'Order Reference', 'name': 'SO3996', 'fieldname_create_date': 'Creation Date', 'create_date': '2015-06-02T12:18:26.279909+00:00', 'fieldname_order_line': 'Order Lines', 'order_line': [{'fieldname_id': 'ID', 'id': 16649, 'fieldname_product_uom': 'Unit of Measure', 'product_uom': 'stuks', 'fieldname_is_expense': 'Is expense', 'is_expense': False}]}]
|
|
@ -1,7 +1,8 @@
|
||||||
# Copyright 2017 ACSONE SA/NV
|
# Copyright 2017 ACSONE SA/NV
|
||||||
|
# Copyright 2022 Camptocamp SA (http://www.camptocamp.com)
|
||||||
|
# Simone Orsi <simahawk@gmail.com>
|
||||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||||
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from odoo import fields, tools
|
from odoo import fields, tools
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
@ -119,6 +120,8 @@ class TestParser(TransactionCase):
|
||||||
self.assertEqual(parser, expected_full_parser)
|
self.assertEqual(parser, expected_full_parser)
|
||||||
|
|
||||||
def test_json_export(self):
|
def test_json_export(self):
|
||||||
|
# will allow to view large dict diff in case of regression
|
||||||
|
self.maxDiff = None
|
||||||
# Enforces TZ to validate the serialization result of a Datetime
|
# Enforces TZ to validate the serialization result of a Datetime
|
||||||
parser = [
|
parser = [
|
||||||
"lang",
|
"lang",
|
||||||
|
@ -168,10 +171,61 @@ class TestParser(TransactionCase):
|
||||||
"create_date": "2019-10-31T14:39:49",
|
"create_date": "2019-10-31T14:39:49",
|
||||||
"date": "2019-10-31",
|
"date": "2019-10-31",
|
||||||
}
|
}
|
||||||
|
expected_json_with_fieldname = {
|
||||||
|
"_fieldname_lang": "Language",
|
||||||
|
"lang": "en_US",
|
||||||
|
"_fieldname_comment": "Notes",
|
||||||
|
"comment": None,
|
||||||
|
"_fieldname_partner_latitude": "Geo Latitude",
|
||||||
|
"_fieldname_name": "Name",
|
||||||
|
"name": "Akretion",
|
||||||
|
"_fieldname_color": "Color Index",
|
||||||
|
"color": 0,
|
||||||
|
"_fieldname_children": "Contact",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"_fieldname_children": "Contact",
|
||||||
|
"children": [],
|
||||||
|
"_fieldname_email": "Email",
|
||||||
|
"email": None,
|
||||||
|
"_fieldname_country": "Country",
|
||||||
|
"country": {
|
||||||
|
"_fieldname_code": "Country Code",
|
||||||
|
"code": "FR",
|
||||||
|
"_fieldname_name": "Country Name",
|
||||||
|
"name": "France",
|
||||||
|
},
|
||||||
|
"_fieldname_name": "Name",
|
||||||
|
"name": "Sebatien Beau",
|
||||||
|
"_fieldname_id": "ID",
|
||||||
|
"id": self.partner.child_ids.id,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"_fieldname_country": "Country",
|
||||||
|
"country": {
|
||||||
|
"_fieldname_code": "Country Code",
|
||||||
|
"code": "FR",
|
||||||
|
"_fieldname_name": "Country Name",
|
||||||
|
"name": "France",
|
||||||
|
},
|
||||||
|
"_fieldname_active": "Active",
|
||||||
|
"active": True,
|
||||||
|
"_fieldname_category_id": "Tags",
|
||||||
|
"category_id": [{"_fieldname_name": "Tag Name", "name": "Inovator"}],
|
||||||
|
"_fieldname_create_date": "Created on",
|
||||||
|
"create_date": "2019-10-31T14:39:49",
|
||||||
|
"_fieldname_date": "Date",
|
||||||
|
"date": "2019-10-31",
|
||||||
|
"partner_latitude": 0.0,
|
||||||
|
}
|
||||||
json_partner = self.partner.jsonify(parser)
|
json_partner = self.partner.jsonify(parser)
|
||||||
|
|
||||||
self.assertDictEqual(json_partner[0], expected_json)
|
self.assertDictEqual(json_partner[0], expected_json)
|
||||||
|
json_partner_with_fieldname = self.partner.jsonify(
|
||||||
|
parser=parser, with_fieldname=True
|
||||||
|
)
|
||||||
|
self.assertDictEqual(
|
||||||
|
json_partner_with_fieldname[0], expected_json_with_fieldname
|
||||||
|
)
|
||||||
# Check that only boolean fields have boolean values into json
|
# Check that only boolean fields have boolean values into json
|
||||||
# By default if a field is not set into Odoo, the value is always False
|
# By default if a field is not set into Odoo, the value is always False
|
||||||
# This value is not the expected one into the json
|
# This value is not the expected one into the json
|
||||||
|
@ -225,6 +279,16 @@ class TestParser(TransactionCase):
|
||||||
self.assertEqual(json["name_resolved"], "name_pidgin") # field resolver
|
self.assertEqual(json["name_resolved"], "name_pidgin") # field resolver
|
||||||
self.assertEqual(json["X"], "X") # added by global resolver
|
self.assertEqual(json["X"], "X") # added by global resolver
|
||||||
|
|
||||||
|
def test_full_parser_resolver_json_key_override(self):
|
||||||
|
self.resolver.write(
|
||||||
|
{"python_code": """result = {"_json_key": "foo", "_value": record.id}"""}
|
||||||
|
)
|
||||||
|
parser = self.category_export.get_json_parser()
|
||||||
|
json = self.category.jsonify(parser)[0]
|
||||||
|
self.assertNotIn("name_resolved", json)
|
||||||
|
self.assertEqual(json["foo"], self.category.id) # field resolver
|
||||||
|
self.assertEqual(json["X"], "X") # added by global resolver
|
||||||
|
|
||||||
def test_simple_parser_translations(self):
|
def test_simple_parser_translations(self):
|
||||||
"""The simple parser result should depend on the context language."""
|
"""The simple parser result should depend on the context language."""
|
||||||
parser = ["name"]
|
parser = ["name"]
|
||||||
|
@ -320,24 +384,25 @@ class TestParser(TransactionCase):
|
||||||
def test_bad_parsers_fail_gracefully(self):
|
def test_bad_parsers_fail_gracefully(self):
|
||||||
rec = self.category
|
rec = self.category
|
||||||
|
|
||||||
logger_patch_path = "odoo.addons.jsonifier.models.models._logger.error"
|
# logging is disabled when testing as it makes too much noise
|
||||||
|
|
||||||
# logging is disabled when testing as it's useless and makes build fail.
|
|
||||||
tools.config["test_enable"] = False
|
tools.config["test_enable"] = False
|
||||||
|
|
||||||
|
logger_name = "odoo.addons.jsonifier.models.models"
|
||||||
bad_field_name = ["Name"]
|
bad_field_name = ["Name"]
|
||||||
with mock.patch(logger_patch_path) as mocked_logger:
|
with self.assertLogs(logger=logger_name, level="WARNING") as capt:
|
||||||
rec.jsonify(bad_field_name, one=True)
|
rec.jsonify(bad_field_name, one=True)
|
||||||
mocked_logger.assert_called()
|
self.assertIn("res.partner.category.Name not availabl", capt.output[0])
|
||||||
|
|
||||||
bad_function_name = {"fields": [{"name": "name", "function": "notafunction"}]}
|
bad_function_name = {"fields": [{"name": "name", "function": "notafunction"}]}
|
||||||
with mock.patch(logger_patch_path) as mocked_logger:
|
with self.assertLogs(logger=logger_name, level="WARNING") as capt:
|
||||||
rec.jsonify(bad_function_name, one=True)
|
rec.jsonify(bad_function_name, one=True)
|
||||||
mocked_logger.assert_called()
|
self.assertIn(
|
||||||
|
"res.partner.category.notafunction not available", capt.output[0]
|
||||||
|
)
|
||||||
|
|
||||||
bad_subparser = {"fields": [({"name": "name"}, [{"name": "subparser_name"}])]}
|
bad_subparser = {"fields": [({"name": "name"}, [{"name": "subparser_name"}])]}
|
||||||
with mock.patch(logger_patch_path) as mocked_logger:
|
with self.assertLogs(logger=logger_name, level="WARNING") as capt:
|
||||||
rec.jsonify(bad_subparser, one=True)
|
rec.jsonify(bad_subparser, one=True)
|
||||||
mocked_logger.assert_called()
|
self.assertIn("res.partner.category.name not relational", capt.output[0])
|
||||||
|
|
||||||
tools.config["test_enable"] = True
|
tools.config["test_enable"] = True
|
||||||
|
|
Loading…
Reference in New Issue