diff --git a/.oca/oca-port/blacklist/jsonifier.json b/.oca/oca-port/blacklist/jsonifier.json new file mode 100644 index 00000000000..f1605ad1441 --- /dev/null +++ b/.oca/oca-port/blacklist/jsonifier.json @@ -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" + } +} diff --git a/jsonifier/exceptions.py b/jsonifier/exceptions.py new file mode 100644 index 00000000000..7fd3fd38484 --- /dev/null +++ b/jsonifier/exceptions.py @@ -0,0 +1,7 @@ +# Copyright 2022 Camptocamp SA (http://www.camptocamp.com) +# Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + + +class SwallableException(Exception): + """An exception that can be safely skipped.""" diff --git a/jsonifier/models/ir_exports.py b/jsonifier/models/ir_exports.py index 5fced0af69c..83f3e76b290 100644 --- a/jsonifier/models/ir_exports.py +++ b/jsonifier/models/ir_exports.py @@ -109,7 +109,8 @@ def get_json_parser(self): if line.target: names = line.target.split("/") 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) lang_parsers[lang] = convert_dict(dict_parser) if list(lang_parsers.keys()) == [False]: @@ -117,7 +118,7 @@ def get_json_parser(self): else: parser["langs"] = lang_parsers if self.global_resolver_id: - parser["resolver"] = self.global_resolver_id + parser["resolver"] = self.global_resolver_id.id if self.language_agnostic: parser["language_agnostic"] = self.language_agnostic return parser diff --git a/jsonifier/models/ir_exports_resolver.py b/jsonifier/models/ir_exports_resolver.py index d448181dd0f..9dd735edfed 100644 --- a/jsonifier/models/ir_exports_resolver.py +++ b/jsonifier/models/ir_exports_resolver.py @@ -6,13 +6,18 @@ help_message = [ "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 value: value of the field", ":param field_type: type of the field", - "For global resolvers:", + "\n" "For global resolvers:", ":param value: JSON dict", ":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 @@ def resolve(self, param, records): else: # param is a field for record in records: values = { + "record": record, "value": record[param.name], "name": param.name, "field_type": param.type, diff --git a/jsonifier/models/models.py b/jsonifier/models/models.py index 715c9552891..9087c8e1217 100644 --- a/jsonifier/models/models.py +++ b/jsonifier/models/models.py @@ -12,6 +12,7 @@ from odoo.tools.misc import format_duration from odoo.tools.translate import _ +from ..exceptions import SwallableException from .utils import convert_simple_to_full_parser _logger = logging.getLogger(__name__) @@ -71,60 +72,112 @@ def _add_json_key(self, values, json_key, value): def _jsonify_record(self, parser, rec, root): """JSONify one record (rec). Private function called by jsonify.""" strict = self.env.context.get("jsonify_record_strict", False) - for field_key in parser: - field_dict, subparser = rec.__parse_field(field_key) - field_name = field_dict["name"] - field = rec._fields.get(field_name) + for field in parser: + field_dict, subparser = rec.__parse_field(field) function = field_dict.get("function") - if not field and not function: - if strict: - # let it fail - rec._fields[field_name] # pylint: disable=pointless-statement - if not tools.config["test_enable"]: - # If running live, log proper error - # so that techies can track it down - _logger.error( - "%(model)s.%(fname)s not available", - {"model": self._name, "fname": field_name}, - ) - continue - json_key = field_dict.get("target", field_name) + 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._function_value(rec, function, field_name) - except UserError: - if strict: - raise - if not tools.config["test_enable"]: - _logger.error( - "%(model)s.%(func)s not available", - {"model": self._name, "func": str(function)}, - ) + value = self._jsonify_record_handle_function( + rec, field_dict, strict + ) + except SwallableException: continue elif subparser: - if not (field.relational or field.type == "reference"): - if strict: - self._jsonify_bad_parser_error(field_name) - if not tools.config["test_enable"]: - _logger.error( - "%(model)s.%(fname)s not relational", - {"model": self._name, "fname": field_name}, - ) + try: + value = self._jsonify_record_handle_subparser( + rec, field_dict, strict, subparser + ) + except SwallableException: continue - value = [ - self._jsonify_record(subparser, r, {}) for r in rec[field_name] - ] - if field.type in ("many2one", "reference"): - value = value[0] if value else None else: - resolver = field_dict.get("resolver") + field = rec._fields[field_dict["name"]] value = rec._jsonify_value(field, rec[field.name]) - value = resolver.resolve(field, rec)[0] if resolver else value - + 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(self, parser, one=False): + def _jsonify_record_validate_field(self, rec, field_dict, strict): + field_name = field_dict["name"] + if field_name not in rec._fields: + if strict: + # let it fail + rec._fields[field_name] # pylint: disable=pointless-statement + else: + if not tools.config["test_enable"]: + # If running live, log proper error + # so that techies can track it down + _logger.warning( + "%(model)s.%(fname)s not available", + {"model": self._name, "fname": field_name}, + ) + raise SwallableException() + return True + + def _jsonify_record_handle_function(self, rec, field_dict, strict): + field_name = field_dict["name"] + function = field_dict["function"] + try: + return self._function_value(rec, function, field_name) + except UserError as err: + if strict: + raise + if not tools.config["test_enable"]: + _logger.error( + "%(model)s.%(func)s not available", + {"model": self._name, "func": str(function)}, + ) + raise SwallableException() from err + + 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 strict: + self._jsonify_bad_parser_error(field_name) + if not tools.config["test_enable"]: + _logger.error( + "%(model)s.%(fname)s not relational", + {"model": self._name, "fname": field_name}, + ) + raise SwallableException() + value = [self._jsonify_record(subparser, r, {}) for r in rec[field_name]] + + if field.type in ("many2one", "reference"): + value = value[0] if value else None + + return value + + def _jsonify_record_handle_resolver(self, rec, field, resolver, json_key): + value = rec._jsonify_value(field, rec[field.name]) + 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 + + def jsonify(self, parser, one=False, with_fieldname=False): """Convert the record according to the given parser. Example of (simple) parser: @@ -156,12 +209,19 @@ def jsonify(self, parser, one=False): if isinstance(parser, list): parser = convert_simple_to_full_parser(parser) 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] parsers = {False: parser["fields"]} if "fields" in parser else parser["langs"] for lang in parsers: 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): self._jsonify_record(parsers[lang], record, json) diff --git a/jsonifier/readme/USAGE.rst b/jsonifier/readme/USAGE.rst new file mode 100644 index 00000000000..7b5ae0cc534 --- /dev/null +++ b/jsonifier/readme/USAGE.rst @@ -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}]}] diff --git a/jsonifier/tests/test_get_parser.py b/jsonifier/tests/test_get_parser.py index 61e69d37b43..afc093d817d 100644 --- a/jsonifier/tests/test_get_parser.py +++ b/jsonifier/tests/test_get_parser.py @@ -1,7 +1,8 @@ # Copyright 2017 ACSONE SA/NV +# Copyright 2022 Camptocamp SA (http://www.camptocamp.com) +# Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -from unittest import mock from odoo import fields, tools from odoo.exceptions import UserError @@ -119,6 +120,8 @@ def test_getting_parser(self): self.assertEqual(parser, expected_full_parser) 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 parser = [ "lang", @@ -168,10 +171,61 @@ def test_json_export(self): "create_date": "2019-10-31T14:39:49", "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) - 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 # 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 @@ -225,6 +279,16 @@ def test_full_parser(self): self.assertEqual(json["name_resolved"], "name_pidgin") # field 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): """The simple parser result should depend on the context language.""" parser = ["name"] @@ -320,24 +384,25 @@ def test_bad_parsers_strict(self): def test_bad_parsers_fail_gracefully(self): rec = self.category - logger_patch_path = "odoo.addons.jsonifier.models.models._logger.error" - - # logging is disabled when testing as it's useless and makes build fail. + # logging is disabled when testing as it makes too much noise tools.config["test_enable"] = False + logger_name = "odoo.addons.jsonifier.models.models" 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) - mocked_logger.assert_called() + self.assertIn("res.partner.category.Name not availabl", capt.output[0]) 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) - mocked_logger.assert_called() + self.assertIn( + "res.partner.category.notafunction not available", capt.output[0] + ) 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) - mocked_logger.assert_called() + self.assertIn("res.partner.category.name not relational", capt.output[0]) tools.config["test_enable"] = True