diff --git a/jsonifier/README.rst b/jsonifier/README.rst new file mode 100644 index 000000000..c0363cd11 --- /dev/null +++ b/jsonifier/README.rst @@ -0,0 +1,245 @@ +========= +JSONifier +========= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |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%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/14.0/jsonifier + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-14-0/server-tools-14-0-jsonifier + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/149/14.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds a 'jsonify' method to every model of the ORM. +It works on the current recordset and requires a single argument 'parser' +that specify the field to extract. + +Example of a simple parser: + + +.. code-block:: python + + parser = [ + 'name', + 'number', + 'create_date', + ('partner_id', ['id', 'display_name', 'ref']) + ('line_id', ['id', ('product_id', ['name']), 'price_unit']) + ] + +In order to be consistent with the Odoo API the jsonify method always +returns a list of objects even if there is only one element in the recordset. + +By default the key into the JSON is the name of the field extracted +from the model. If you need to specify an alternate name to use as key, you +can define your mapping as follow into the parser definition: + +.. code-block:: python + + parser = [ + 'field_name:json_key' + ] + +.. code-block:: python + + + parser = [ + 'name', + 'number', + 'create_date:creationDate', + ('partner_id:partners', ['id', 'display_name', 'ref']) + ('line_id:lines', ['id', ('product_id', ['name']), 'price_unit']) + ] + +If you need to parse the value of a field in a custom way, +you can pass a callable or the name of a method on the model: + +.. code-block:: python + + parser = [ + ('name', "jsonify_name") # method name + ('number', lambda rec, field_name: rec[field_name] * 2)) # callable + ] + +Also the module provide a method "get_json_parser" on the ir.exports object +that generate a parser from an ir.exports configuration. + +Further features are available for advanced uses. +It defines a simple "resolver" model that has a "python_code" field and a resolve +function so that arbitrary functions can be configured to transform fields, +or process the resulting dictionary. +It is also to specify a lang to extract the translation of any given field. + +To use these features, a full parser follows the following structure: + +.. code-block:: python + + parser = { + "resolver": 3, + "language_agnostic": True, + "langs": { + False: [ + {'name': 'description'}, + {'name': 'number', 'resolver': 5}, + ({'name': 'partner_id', 'target': 'partner'}, [{'name': 'display_name'}]) + ], + 'fr_FR': [ + {'name': 'description', 'target': 'descriptions_fr'}, + ({'name': 'partner_id', 'target': 'partner'}, [{'name': 'description', 'target': 'description_fr'}]) + ], + } + } + + +One would get a result having this structure (note that the translated fields are merged in the same dictionary): + +.. code-block:: python + + exported_json == { + "description": "English description", + "description_fr": "French description, voilà", + "number": 42, + "partner": { + "display_name": "partner name", + "description_fr": "French description of that partner", + }, + } + + +Note that a resolver can be passed either as a recordset or as an id, so as to be fully serializable. +A slightly simpler version in case the translation of fields is not needed, +but other features like custom resolvers are: + +.. code-block:: python + + parser = { + "resolver": 3, + "fields": [ + {'name': 'description'}, + {'name': 'number', 'resolver': 5}, + ({'name': 'partner_id', 'target': 'partners'}, [{'name': 'display_name'}]), + ], + } + + +By passing the `fields` key instead of `langs`, we have essentially the same behaviour as simple parsers, +with the added benefit of being able to use resolvers. + +Standard use-cases of resolvers are: +- give field-specific defaults (e.g. `""` instead of `None`) +- cast a field type (e.g. `int()`) +- alias a particular field for a specific export +- ... + +A simple parser is simply translated into a full parser at export. + +If the global resolver is given, then the json_dict goes through: + +.. code-block:: python + + resolver.resolve(dict, record) + +Which allows to add external data from the context or transform the dictionary +if necessary. Similarly if given for a field the resolver evaluates the result. + +It is possible for a target to have a marshaller by ending the target with '=list': +in that case the result is put into a list. + +.. code-block:: python + + parser = { + fields: [ + {'name': 'name'}, + {'name': 'field_1', 'target': 'customTags=list'}, + {'name': 'field_2', 'target': 'customTags=list'}, + ] + } + + +Would result in the following JSON structure: + +.. code-block:: python + + { + 'name': 'record_name', + 'customTags': ['field_1_value', 'field_2_value'], + } + +The intended use-case is to be compatible with APIs that require all translated +parameters to be exported simultaneously, and ask for custom properties to be +put in a sub-dictionary. +Since it is often the case that some of these requirements are optional, +new requirements could be met without needing to add field or change any code. + +Note that the export values with the simple parser depends on the record's lang; +this is in contrast with full parsers which are designed to be language agnostic. + + +NOTE: this module was named `base_jsonify` till version 14.0.1.5.0. + +**Table of contents** + +.. contents:: + :local: + +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 +~~~~~~~ + +* Akretion +* ACSONE +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* BEAU Sébastien +* Raphaël Reverdy +* Laurent Mignon +* Nans Lefebvre +* Simone Orsi +* Iván Todorovich + +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/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/jsonifier/__init__.py b/jsonifier/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/jsonifier/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/jsonifier/__manifest__.py b/jsonifier/__manifest__.py new file mode 100644 index 000000000..91f355bda --- /dev/null +++ b/jsonifier/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2017-2018 Akretion (http://www.akretion.com) +# Sébastien BEAU +# Raphaël Reverdy +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +{ + "name": "JSONifier", + "summary": "JSON-ify data for all models", + "version": "16.0.0.0.0", + "category": "Uncategorized", + "website": "https://github.com/OCA/server-tools", + "author": "Akretion, ACSONE, Camptocamp, Odoo Community Association (OCA)", + "license": "LGPL-3", + "installable": True, + "depends": ["base"], + "data": [ + "security/ir.model.access.csv", + "views/ir_exports_view.xml", + "views/ir_exports_resolver_view.xml", + ], + "demo": [ + "demo/resolver_demo.xml", + "demo/export_demo.xml", + "demo/ir.exports.line.csv", + ], +} diff --git a/jsonifier/demo/export_demo.xml b/jsonifier/demo/export_demo.xml new file mode 100644 index 000000000..a060d3002 --- /dev/null +++ b/jsonifier/demo/export_demo.xml @@ -0,0 +1,7 @@ + + + + Partner Export + res.partner + + diff --git a/jsonifier/demo/ir.exports.line.csv b/jsonifier/demo/ir.exports.line.csv new file mode 100644 index 000000000..4744c11ea --- /dev/null +++ b/jsonifier/demo/ir.exports.line.csv @@ -0,0 +1,16 @@ +id,export_id/id,name +name,ir_exp_partner,name +active,ir_exp_partner,active +partner_latitude,ir_exp_partner,partner_latitude +color,ir_exp_partner,color +category_id_name,ir_exp_partner,category_id/name +country_id_name,ir_exp_partner,country_id/name +country_id_code,ir_exp_partner,country_id/code +child_ids_name,ir_exp_partner,child_ids/name +child_ids_id,ir_exp_partner,child_ids/id +child_ids_email,ir_exp_partner,child_ids/email +child_ids_country_id_name,ir_exp_partner,child_ids/country_id/name +child_ids_country_id_code,ir_exp_partner,child_ids/country_id/code +child_ids_child_ids_name,ir_exp_partner,child_ids/child_ids/name +lang,ir_exp_partner,lang +comment,ir_exp_partner,comment diff --git a/jsonifier/demo/resolver_demo.xml b/jsonifier/demo/resolver_demo.xml new file mode 100644 index 000000000..540302be2 --- /dev/null +++ b/jsonifier/demo/resolver_demo.xml @@ -0,0 +1,12 @@ + + + + ExtraData dictionary (number/text) + +is_number = field_type in ('integer', 'float') +ftype = "NUMBER" if is_number else "TEXT" +value = value if is_number else str(value) +result = {"Key": name, "Value": value, "Type": ftype, "IsPublic": True} + + + diff --git a/jsonifier/i18n/ca.po b/jsonifier/i18n/ca.po new file mode 100644 index 000000000..665864179 --- /dev/null +++ b/jsonifier/i18n/ca.po @@ -0,0 +1,236 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * jsonifier +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: ca\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports_line__instance_method_name +msgid "A method defined on the model that takes a record and a field_name" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__active +msgid "Active" +msgstr "" + +#. module: jsonifier +#: model:ir.model,name:jsonifier.model_base +msgid "Base" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports_resolver__python_code +msgid "" +"Compute the result from 'value' by setting the variable 'result'.\n" +"For fields resolvers:\n" +":param name: name of the field\n" +":param value: value of the field\n" +":param field_type: type of the field\n" +"For global resolvers:\n" +":param value: JSON dict\n" +":param record: the record" +msgstr "" + +#. module: jsonifier +#: model_terms:ir.ui.view,arch_db:jsonifier.view_ir_exports +msgid "Configuration" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__create_uid +msgid "Created by" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__create_date +msgid "Created on" +msgstr "" + +#. module: jsonifier +#: model:ir.actions.act_window,name:jsonifier.act_ui_exports_resolver_view +#: model:ir.ui.menu,name:jsonifier.ui_exports_resolvers +msgid "Custom Export Resolvers" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports__global_resolver_id +msgid "Custom global resolver" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__resolver_id +msgid "Custom resolver" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports__display_name +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__display_name +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__display_name +msgid "Display Name" +msgstr "" + +#. module: jsonifier +#: code:addons/jsonifier/models/ir_exports_line.py:0 +#, python-format +msgid "Either set a function or a resolver, not both." +msgstr "" + +#. module: jsonifier +#: model:ir.actions.act_window,name:jsonifier.act_ui_exports_view +#: model:ir.ui.menu,name:jsonifier.ui_exports +msgid "Export Fields" +msgstr "" + +#. module: jsonifier +#: model:ir.model,name:jsonifier.model_ir_exports_resolver +msgid "Export Resolver" +msgstr "" + +#. module: jsonifier +#: model:ir.model,name:jsonifier.model_ir_exports +msgid "Exports" +msgstr "" + +#. module: jsonifier +#: model:ir.model,name:jsonifier.model_ir_exports_line +msgid "Exports Line" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields.selection,name:jsonifier.selection__ir_exports_resolver__type__field +msgid "Field" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__instance_method_name +msgid "Function" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields.selection,name:jsonifier.selection__ir_exports_resolver__type__global +msgid "Global" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports__id +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__id +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__id +msgid "ID" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports_line__lang_id +msgid "If set, the language in which the field is exported" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports__global_resolver_id +msgid "If set, will apply the global resolver to the result" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports_line__resolver_id +msgid "If set, will apply the resolver on the field value" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports__language_agnostic +msgid "" +"If set, will set the lang to False when exporting lines without lang, " +"otherwise it uses the lang in the given context to export these fields" +msgstr "" + +#. module: jsonifier +#: model_terms:ir.ui.view,arch_db:jsonifier.view_ir_exports +msgid "Index" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__lang_id +msgid "Language" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports__language_agnostic +msgid "Language Agnostic" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports____last_update +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line____last_update +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver____last_update +msgid "Last Modified on" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__write_date +msgid "Last Updated on" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__name +msgid "Name" +msgstr "" + +#. module: jsonifier +#: code:addons/jsonifier/models/ir_exports_line.py:0 +#, python-format +msgid "Name and Target must have the same hierarchy depth" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__python_code +msgid "Python Code" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports__smart_search +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__smart_search +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__smart_search +msgid "Smart Search" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__target +msgid "Target" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports_line__target +msgid "" +"The complete path to the field where you can specify a target on the step as " +"field:target" +msgstr "" + +#. module: jsonifier +#: code:addons/jsonifier/models/ir_exports_line.py:0 +#, python-format +msgid "The target must reference the same field as in name '%s' not in '%s'" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__type +msgid "Type" +msgstr "" + +#. module: jsonifier +#: code:addons/jsonifier/models/models.py:0 +#, python-format +msgid "Wrong parser configuration for field: `%s`" +msgstr "" diff --git a/jsonifier/i18n/jsonifier.pot b/jsonifier/i18n/jsonifier.pot new file mode 100644 index 000000000..34c42d3fb --- /dev/null +++ b/jsonifier/i18n/jsonifier.pot @@ -0,0 +1,235 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * jsonifier +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports_line__instance_method_name +msgid "A method defined on the model that takes a record and a field_name" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__active +msgid "Active" +msgstr "" + +#. module: jsonifier +#: model:ir.model,name:jsonifier.model_base +msgid "Base" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports_resolver__python_code +msgid "" +"Compute the result from 'value' by setting the variable 'result'.\n" +"For fields resolvers:\n" +":param name: name of the field\n" +":param value: value of the field\n" +":param field_type: type of the field\n" +"For global resolvers:\n" +":param value: JSON dict\n" +":param record: the record" +msgstr "" + +#. module: jsonifier +#: model_terms:ir.ui.view,arch_db:jsonifier.view_ir_exports +msgid "Configuration" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__create_uid +msgid "Created by" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__create_date +msgid "Created on" +msgstr "" + +#. module: jsonifier +#: model:ir.actions.act_window,name:jsonifier.act_ui_exports_resolver_view +#: model:ir.ui.menu,name:jsonifier.ui_exports_resolvers +msgid "Custom Export Resolvers" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports__global_resolver_id +msgid "Custom global resolver" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__resolver_id +msgid "Custom resolver" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports__display_name +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__display_name +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__display_name +msgid "Display Name" +msgstr "" + +#. module: jsonifier +#: code:addons/jsonifier/models/ir_exports_line.py:0 +#, python-format +msgid "Either set a function or a resolver, not both." +msgstr "" + +#. module: jsonifier +#: model:ir.actions.act_window,name:jsonifier.act_ui_exports_view +#: model:ir.ui.menu,name:jsonifier.ui_exports +msgid "Export Fields" +msgstr "" + +#. module: jsonifier +#: model:ir.model,name:jsonifier.model_ir_exports_resolver +msgid "Export Resolver" +msgstr "" + +#. module: jsonifier +#: model:ir.model,name:jsonifier.model_ir_exports +msgid "Exports" +msgstr "" + +#. module: jsonifier +#: model:ir.model,name:jsonifier.model_ir_exports_line +msgid "Exports Line" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields.selection,name:jsonifier.selection__ir_exports_resolver__type__field +msgid "Field" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__instance_method_name +msgid "Function" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields.selection,name:jsonifier.selection__ir_exports_resolver__type__global +msgid "Global" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports__id +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__id +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__id +msgid "ID" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports_line__lang_id +msgid "If set, the language in which the field is exported" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports__global_resolver_id +msgid "If set, will apply the global resolver to the result" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports_line__resolver_id +msgid "If set, will apply the resolver on the field value" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports__language_agnostic +msgid "" +"If set, will set the lang to False when exporting lines without lang, " +"otherwise it uses the lang in the given context to export these fields" +msgstr "" + +#. module: jsonifier +#: model_terms:ir.ui.view,arch_db:jsonifier.view_ir_exports +msgid "Index" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__lang_id +msgid "Language" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports__language_agnostic +msgid "Language Agnostic" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports____last_update +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line____last_update +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver____last_update +msgid "Last Modified on" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__write_date +msgid "Last Updated on" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__name +msgid "Name" +msgstr "" + +#. module: jsonifier +#: code:addons/jsonifier/models/ir_exports_line.py:0 +#, python-format +msgid "Name and Target must have the same hierarchy depth" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__python_code +msgid "Python Code" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports__smart_search +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__smart_search +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__smart_search +msgid "Smart Search" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__target +msgid "Target" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports_line__target +msgid "" +"The complete path to the field where you can specify a target on the step as" +" field:target" +msgstr "" + +#. module: jsonifier +#: code:addons/jsonifier/models/ir_exports_line.py:0 +#, python-format +msgid "The target must reference the same field as in name '%s' not in '%s'" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__type +msgid "Type" +msgstr "" + +#. module: jsonifier +#: code:addons/jsonifier/models/models.py:0 +#, python-format +msgid "Wrong parser configuration for field: `%s`" +msgstr "" diff --git a/jsonifier/i18n/zh_CN.po b/jsonifier/i18n/zh_CN.po new file mode 100644 index 000000000..af4aec70b --- /dev/null +++ b/jsonifier/i18n/zh_CN.po @@ -0,0 +1,238 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * jsonifier +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2019-08-31 04:35+0000\n" +"Last-Translator: 黎伟杰 <674416404@qq.com>\n" +"Language-Team: none\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Weblate 3.8\n" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports_line__instance_method_name +msgid "A method defined on the model that takes a record and a field_name" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__active +msgid "Active" +msgstr "" + +#. module: jsonifier +#: model:ir.model,name:jsonifier.model_base +msgid "Base" +msgstr "基础" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports_resolver__python_code +msgid "" +"Compute the result from 'value' by setting the variable 'result'.\n" +"For fields resolvers:\n" +":param name: name of the field\n" +":param value: value of the field\n" +":param field_type: type of the field\n" +"For global resolvers:\n" +":param value: JSON dict\n" +":param record: the record" +msgstr "" + +#. module: jsonifier +#: model_terms:ir.ui.view,arch_db:jsonifier.view_ir_exports +msgid "Configuration" +msgstr "配置" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__create_uid +msgid "Created by" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__create_date +msgid "Created on" +msgstr "" + +#. module: jsonifier +#: model:ir.actions.act_window,name:jsonifier.act_ui_exports_resolver_view +#: model:ir.ui.menu,name:jsonifier.ui_exports_resolvers +msgid "Custom Export Resolvers" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports__global_resolver_id +msgid "Custom global resolver" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__resolver_id +msgid "Custom resolver" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports__display_name +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__display_name +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__display_name +msgid "Display Name" +msgstr "" + +#. module: jsonifier +#: code:addons/jsonifier/models/ir_exports_line.py:0 +#, python-format +msgid "Either set a function or a resolver, not both." +msgstr "" + +#. module: jsonifier +#: model:ir.actions.act_window,name:jsonifier.act_ui_exports_view +#: model:ir.ui.menu,name:jsonifier.ui_exports +msgid "Export Fields" +msgstr "导出字段" + +#. module: jsonifier +#: model:ir.model,name:jsonifier.model_ir_exports_resolver +msgid "Export Resolver" +msgstr "" + +#. module: jsonifier +#: model:ir.model,name:jsonifier.model_ir_exports +msgid "Exports" +msgstr "导出" + +#. module: jsonifier +#: model:ir.model,name:jsonifier.model_ir_exports_line +msgid "Exports Line" +msgstr "导出行" + +#. module: jsonifier +#: model:ir.model.fields.selection,name:jsonifier.selection__ir_exports_resolver__type__field +msgid "Field" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__instance_method_name +msgid "Function" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields.selection,name:jsonifier.selection__ir_exports_resolver__type__global +msgid "Global" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports__id +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__id +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__id +msgid "ID" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports_line__lang_id +msgid "If set, the language in which the field is exported" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports__global_resolver_id +msgid "If set, will apply the global resolver to the result" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports_line__resolver_id +msgid "If set, will apply the resolver on the field value" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports__language_agnostic +msgid "" +"If set, will set the lang to False when exporting lines without lang, " +"otherwise it uses the lang in the given context to export these fields" +msgstr "" + +#. module: jsonifier +#: model_terms:ir.ui.view,arch_db:jsonifier.view_ir_exports +msgid "Index" +msgstr "索引" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__lang_id +msgid "Language" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports__language_agnostic +msgid "Language Agnostic" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports____last_update +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line____last_update +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver____last_update +msgid "Last Modified on" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__write_date +msgid "Last Updated on" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__name +msgid "Name" +msgstr "" + +#. module: jsonifier +#: code:addons/jsonifier/models/ir_exports_line.py:0 +#, python-format +msgid "Name and Target must have the same hierarchy depth" +msgstr "名称和别名必须具有相同的层次结构深度" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__python_code +msgid "Python Code" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports__smart_search +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__smart_search +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__smart_search +msgid "Smart Search" +msgstr "" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_line__target +msgid "Target" +msgstr "别名" + +#. module: jsonifier +#: model:ir.model.fields,help:jsonifier.field_ir_exports_line__target +msgid "" +"The complete path to the field where you can specify a target on the step as " +"field:target" +msgstr "字段的完整路径,您可以在其中指定步骤作为字段的别名:别名" + +#. module: jsonifier +#: code:addons/jsonifier/models/ir_exports_line.py:0 +#, python-format +msgid "The target must reference the same field as in name '%s' not in '%s'" +msgstr "别名必须引用与名称相同的字段'%s'不在'%s'" + +#. module: jsonifier +#: model:ir.model.fields,field_description:jsonifier.field_ir_exports_resolver__type +msgid "Type" +msgstr "" + +#. module: jsonifier +#: code:addons/jsonifier/models/models.py:0 +#, fuzzy, python-format +msgid "Wrong parser configuration for field: `%s`" +msgstr "错误的解析器配置 %s" diff --git a/jsonifier/models/__init__.py b/jsonifier/models/__init__.py new file mode 100644 index 000000000..cd8aff409 --- /dev/null +++ b/jsonifier/models/__init__.py @@ -0,0 +1,5 @@ +from . import utils +from . import models +from . import ir_exports +from . import ir_exports_line +from . import ir_exports_resolver diff --git a/jsonifier/models/ir_exports.py b/jsonifier/models/ir_exports.py new file mode 100644 index 000000000..5fced0af6 --- /dev/null +++ b/jsonifier/models/ir_exports.py @@ -0,0 +1,123 @@ +# © 2017 Akretion (http://www.akretion.com) +# Sébastien BEAU +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from collections import OrderedDict + +from odoo import fields, models +from odoo.tools import ormcache + + +def partition(line, accessor): + """Partition a recordset according to an accessor (e.g. a lambda). + Returns a dictionary whose keys are the values obtained from accessor, + and values are the items that have this value. + Example: partition([{"name": "ax"}, {"name": "by"}], lambda x: "x" in x["name"]) + => {True: [{"name": "ax"}], False: [{"name": "by"}]} + """ + result = {} + for item in line: + key = accessor(item) + if key not in result: + result[key] = [] + result[key].append(item) + return result + + +def update_dict(data, fields, options): + """Contruct a tree of fields. + + Example: + + { + "name": True, + "resource": True, + } + + Order of keys is important. + """ + field = fields[0] + if len(fields) == 1: + if field == ".id": + field = "id" + data[field] = (True, options) + else: + if field not in data: + data[field] = (False, OrderedDict()) + update_dict(data[field][1], fields[1:], options) + + +def convert_dict(dict_parser): + """Convert dict returned by update_dict to list consistent w/ Odoo API. + + The list is composed of strings (field names or targets) or tuples. + """ + parser = [] + for field, value in dict_parser.items(): + if value[0] is True: # is a leaf + parser.append(field_dict(field, value[1])) + else: + parser.append((field_dict(field), convert_dict(value[1]))) + return parser + + +def field_dict(field, options=None): + """Create a parser dict for the field field.""" + result = {"name": field.split(":")[0]} + if len(field.split(":")) > 1: + result["target"] = field.split(":")[1] + for option in options or {}: + if options[option]: + result[option] = options[option] + return result + + +class IrExports(models.Model): + _inherit = "ir.exports" + + language_agnostic = fields.Boolean( + default=False, + help="If set, will set the lang to False when exporting lines without lang," + " otherwise it uses the lang in the given context to export these fields", + ) + + global_resolver_id = fields.Many2one( + comodel_name="ir.exports.resolver", + string="Custom global resolver", + domain="[('type', '=', 'global')]", + help="If set, will apply the global resolver to the result", + ) + + @ormcache( + "self.language_agnostic", + "self.global_resolver_id.id", + "tuple(self.export_fields.mapped('write_date'))", + ) + def get_json_parser(self): + """Creates a parser from ir.exports record and return it. + + The final parser can be used to "jsonify" records of ir.export's model. + """ + self.ensure_one() + parser = {} + lang_to_lines = partition(self.export_fields, lambda l: l.lang_id.code) + lang_parsers = {} + for lang in lang_to_lines: + dict_parser = OrderedDict() + for line in lang_to_lines[lang]: + names = line.name.split("/") + if line.target: + names = line.target.split("/") + function = line.instance_method_name + options = {"resolver": line.resolver_id, "function": function} + update_dict(dict_parser, names, options) + lang_parsers[lang] = convert_dict(dict_parser) + if list(lang_parsers.keys()) == [False]: + parser["fields"] = lang_parsers[False] + else: + parser["langs"] = lang_parsers + if self.global_resolver_id: + parser["resolver"] = self.global_resolver_id + if self.language_agnostic: + parser["language_agnostic"] = self.language_agnostic + return parser diff --git a/jsonifier/models/ir_exports_line.py b/jsonifier/models/ir_exports_line.py new file mode 100644 index 000000000..b1a7134d8 --- /dev/null +++ b/jsonifier/models/ir_exports_line.py @@ -0,0 +1,58 @@ +# Copyright 2017 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class IrExportsLine(models.Model): + _inherit = "ir.exports.line" + + target = fields.Char( + help="The complete path to the field where you can specify a " + "target on the step as field:target", + ) + active = fields.Boolean(default=True) + lang_id = fields.Many2one( + comodel_name="res.lang", + string="Language", + help="If set, the language in which the field is exported", + ) + resolver_id = fields.Many2one( + comodel_name="ir.exports.resolver", + string="Custom resolver", + help="If set, will apply the resolver on the field value", + ) + instance_method_name = fields.Char( + string="Function", + help="A method defined on the model that takes a record and a field_name", + ) + + @api.constrains("resolver_id", "instance_method_name") + def _check_function_resolver(self): + for rec in self: + if rec.resolver_id and rec.instance_method_name: + msg = _("Either set a function or a resolver, not both.") + raise ValidationError(msg) + + @api.constrains("target", "name") + def _check_target(self): + for rec in self: + if not rec.target: + continue + names = rec.name.split("/") + names_with_target = rec.target.split("/") + if len(names) != len(names_with_target): + raise ValidationError( + _("Name and Target must have the same hierarchy depth") + ) + for name, name_with_target in zip(names, names_with_target): + field_name = name_with_target.split(":")[0] + if name != field_name: + raise ValidationError( + _( + "The target must reference the same field as in " + "name '%(name)s' not in '%(name_with_target)s'" + ) + % dict(name=name, name_with_target=name_with_target) + ) diff --git a/jsonifier/models/ir_exports_resolver.py b/jsonifier/models/ir_exports_resolver.py new file mode 100644 index 000000000..d448181dd --- /dev/null +++ b/jsonifier/models/ir_exports_resolver.py @@ -0,0 +1,52 @@ +# Copyright 2020 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import fields, models +from odoo.tools.safe_eval import safe_eval + +help_message = [ + "Compute the result from 'value' by setting the variable 'result'.", + "For fields resolvers:", + ":param name: name of the field", + ":param value: value of the field", + ":param field_type: type of the field", + "For global resolvers:", + ":param value: JSON dict", + ":param record: the record", +] + + +class FieldResolver(models.Model): + """Arbitrary function to process a field or a dict at export time.""" + + _name = "ir.exports.resolver" + _description = "Export Resolver" + + name = fields.Char() + type = fields.Selection([("field", "Field"), ("global", "Global")]) + python_code = fields.Text( + default="\n".join(["# " + h for h in help_message] + ["result = value"]), + help="\n".join(help_message), + ) + + def resolve(self, param, records): + self.ensure_one() + result = [] + context = records.env.context + if self.type == "global": + assert len(param) == len(records) + for value, record in zip(param, records): + values = {"value": value, "record": record, "context": context} + safe_eval(self.python_code, values, mode="exec", nocopy=True) + result.append(values["result"]) + else: # param is a field + for record in records: + values = { + "value": record[param.name], + "name": param.name, + "field_type": param.type, + "context": context, + } + safe_eval(self.python_code, values, mode="exec", nocopy=True) + result.append(values["result"]) + return result diff --git a/jsonifier/models/models.py b/jsonifier/models/models.py new file mode 100644 index 000000000..c9ea16450 --- /dev/null +++ b/jsonifier/models/models.py @@ -0,0 +1,207 @@ +# Copyright 2017 Akretion (http://www.akretion.com) +# Sébastien BEAU +# Raphaël Reverdy +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import logging + +from odoo import api, fields, models, tools +from odoo.exceptions import UserError +from odoo.tools.misc import format_duration +from odoo.tools.translate import _ + +from .utils import convert_simple_to_full_parser + +_logger = logging.getLogger(__name__) + + +class Base(models.AbstractModel): + + _inherit = "base" + + @api.model + def __parse_field(self, parser_field): + """Deduct how to handle a field from its parser.""" + return parser_field if isinstance(parser_field, tuple) else (parser_field, None) + + @api.model + def _jsonify_bad_parser_error(self, field_name): + raise UserError(_("Wrong parser configuration for field: `%s`") % field_name) + + def _function_value(self, record, function, field_name): + if function in dir(record): + method = getattr(record, function, None) + return method(field_name) + elif callable(function): + return function(record, field_name) + else: + return self._jsonify_bad_parser_error(field_name) + + @api.model + def _jsonify_value(self, field, value): + """Override this function to support new field types.""" + if value is False and field.type != "boolean": + value = None + elif field.type == "date": + value = fields.Date.to_date(value).isoformat() + elif field.type == "datetime": + # Ensures value is a datetime + value = fields.Datetime.to_datetime(value) + value = value.isoformat() + elif field.type in ("many2one", "reference"): + value = value.display_name if value else None + elif field.type in ("one2many", "many2many"): + value = [v.display_name for v in value] + return value + + @api.model + def _add_json_key(self, values, json_key, value): + """To manage defaults, you can use a specific resolver.""" + key, sep, marshaller = json_key.partition("=") + if marshaller == "list": # sublist field + if not values.get(key): + values[key] = [] + values[key].append(value) + else: + values[key] = value + + @api.model + 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 in parser: + field_dict, subparser = rec.__parse_field(field) + 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 + 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) + field = rec._fields[field_name] + if field_dict.get("function"): + function = field_dict["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)}, + ) + 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}, + ) + 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") + value = rec._jsonify_value(field, rec[field.name]) + value = resolver.resolve(field, rec)[0] if resolver else value + + self._add_json_key(root, json_key, value) + return root + + def jsonify(self, parser, one=False): + """Convert the record according to the given parser. + + Example of (simple) parser: + parser = [ + 'name', + 'number', + 'create_date', + ('partner_id', ['id', 'display_name', 'ref']) + ('shipping_id', callable) + ('delivery_id', "record_method") + ('line_id', ['id', ('product_id', ['name']), 'price_unit']) + ] + + In order to be consistent with the Odoo API the jsonify method always + returns a list of objects even if there is only one element in input. + You can change this behavior by passing `one=True` to get only one element. + + By default the key into the JSON is the name of the field extracted + from the model. If you need to specify an alternate name to use as + key, you can define your mapping as follow into the parser definition: + + parser = [ + 'field_name:json_key' + ] + + """ + if one: + self.ensure_one() + if isinstance(parser, list): + parser = convert_simple_to_full_parser(parser) + resolver = parser.get("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 + for record, json in zip(records, results): + self._jsonify_record(parsers[lang], record, json) + + if resolver: + results = resolver.resolve(results, self) + return results[0] if one else results + + # HELPERS + + def _jsonify_m2o_to_id(self, fname): + """Helper to get an ID only from a m2o field. + + Example: + + m2o_id + m2o_id:rel_id + _jsonify_m2o_to_id + + """ + return self[fname].id + + def _jsonify_x2m_to_ids(self, fname): + """Helper to get a list of IDs only from a o2m or m2m field. + + Example: + + m2m_ids + m2m_ids:rel_ids + _jsonify_x2m_to_ids + + """ + return self[fname].ids + + def _jsonify_format_duration(self, fname): + """Helper to format a Float-like duration to string 00:00. + + Example: + + duration + _jsonify_format_duration + + """ + return format_duration(self[fname]) diff --git a/jsonifier/models/utils.py b/jsonifier/models/utils.py new file mode 100644 index 000000000..dba45cc06 --- /dev/null +++ b/jsonifier/models/utils.py @@ -0,0 +1,35 @@ +def convert_simple_to_full_parser(parser): + """Convert a simple API style parser to a full parser""" + assert isinstance(parser, list) + return {"fields": _convert_parser(parser)} + + +def _convert_field(fld, function=None): + """Return a dict from the string encoding a field to export. + The : is used as a separator to specify a target, if any. + """ + name, sep, target = fld.partition(":") + field_dict = {"name": name} + if target: + field_dict["target"] = target + if function: + field_dict["function"] = function + return field_dict + + +def _convert_parser(parser): + """Recursively process each list to replace encoded fields as string + by dicts specifying each attribute by its relevant key. + """ + result = [] + for line in parser: + if isinstance(line, str): + field_def = _convert_field(line) + else: + fld, sub = line + if callable(sub) or isinstance(sub, str): + field_def = _convert_field(fld, sub) + else: + field_def = (_convert_field(fld), _convert_parser(sub)) + result.append(field_def) + return result diff --git a/jsonifier/readme/CONTRIBUTORS.rst b/jsonifier/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..dc005d8a6 --- /dev/null +++ b/jsonifier/readme/CONTRIBUTORS.rst @@ -0,0 +1,6 @@ +* BEAU Sébastien +* Raphaël Reverdy +* Laurent Mignon +* Nans Lefebvre +* Simone Orsi +* Iván Todorovich diff --git a/jsonifier/readme/DESCRIPTION.rst b/jsonifier/readme/DESCRIPTION.rst new file mode 100644 index 000000000..ca534186a --- /dev/null +++ b/jsonifier/readme/DESCRIPTION.rst @@ -0,0 +1,166 @@ +This module adds a 'jsonify' method to every model of the ORM. +It works on the current recordset and requires a single argument 'parser' +that specify the field to extract. + +Example of a simple parser: + + +.. code-block:: python + + parser = [ + 'name', + 'number', + 'create_date', + ('partner_id', ['id', 'display_name', 'ref']) + ('line_id', ['id', ('product_id', ['name']), 'price_unit']) + ] + +In order to be consistent with the Odoo API the jsonify method always +returns a list of objects even if there is only one element in the recordset. + +By default the key into the JSON is the name of the field extracted +from the model. If you need to specify an alternate name to use as key, you +can define your mapping as follow into the parser definition: + +.. code-block:: python + + parser = [ + 'field_name:json_key' + ] + +.. code-block:: python + + + parser = [ + 'name', + 'number', + 'create_date:creationDate', + ('partner_id:partners', ['id', 'display_name', 'ref']) + ('line_id:lines', ['id', ('product_id', ['name']), 'price_unit']) + ] + +If you need to parse the value of a field in a custom way, +you can pass a callable or the name of a method on the model: + +.. code-block:: python + + parser = [ + ('name', "jsonify_name") # method name + ('number', lambda rec, field_name: rec[field_name] * 2)) # callable + ] + +Also the module provide a method "get_json_parser" on the ir.exports object +that generate a parser from an ir.exports configuration. + +Further features are available for advanced uses. +It defines a simple "resolver" model that has a "python_code" field and a resolve +function so that arbitrary functions can be configured to transform fields, +or process the resulting dictionary. +It is also to specify a lang to extract the translation of any given field. + +To use these features, a full parser follows the following structure: + +.. code-block:: python + + parser = { + "resolver": 3, + "language_agnostic": True, + "langs": { + False: [ + {'name': 'description'}, + {'name': 'number', 'resolver': 5}, + ({'name': 'partner_id', 'target': 'partner'}, [{'name': 'display_name'}]) + ], + 'fr_FR': [ + {'name': 'description', 'target': 'descriptions_fr'}, + ({'name': 'partner_id', 'target': 'partner'}, [{'name': 'description', 'target': 'description_fr'}]) + ], + } + } + + +One would get a result having this structure (note that the translated fields are merged in the same dictionary): + +.. code-block:: python + + exported_json == { + "description": "English description", + "description_fr": "French description, voilà", + "number": 42, + "partner": { + "display_name": "partner name", + "description_fr": "French description of that partner", + }, + } + + +Note that a resolver can be passed either as a recordset or as an id, so as to be fully serializable. +A slightly simpler version in case the translation of fields is not needed, +but other features like custom resolvers are: + +.. code-block:: python + + parser = { + "resolver": 3, + "fields": [ + {'name': 'description'}, + {'name': 'number', 'resolver': 5}, + ({'name': 'partner_id', 'target': 'partners'}, [{'name': 'display_name'}]), + ], + } + + +By passing the `fields` key instead of `langs`, we have essentially the same behaviour as simple parsers, +with the added benefit of being able to use resolvers. + +Standard use-cases of resolvers are: +- give field-specific defaults (e.g. `""` instead of `None`) +- cast a field type (e.g. `int()`) +- alias a particular field for a specific export +- ... + +A simple parser is simply translated into a full parser at export. + +If the global resolver is given, then the json_dict goes through: + +.. code-block:: python + + resolver.resolve(dict, record) + +Which allows to add external data from the context or transform the dictionary +if necessary. Similarly if given for a field the resolver evaluates the result. + +It is possible for a target to have a marshaller by ending the target with '=list': +in that case the result is put into a list. + +.. code-block:: python + + parser = { + fields: [ + {'name': 'name'}, + {'name': 'field_1', 'target': 'customTags=list'}, + {'name': 'field_2', 'target': 'customTags=list'}, + ] + } + + +Would result in the following JSON structure: + +.. code-block:: python + + { + 'name': 'record_name', + 'customTags': ['field_1_value', 'field_2_value'], + } + +The intended use-case is to be compatible with APIs that require all translated +parameters to be exported simultaneously, and ask for custom properties to be +put in a sub-dictionary. +Since it is often the case that some of these requirements are optional, +new requirements could be met without needing to add field or change any code. + +Note that the export values with the simple parser depends on the record's lang; +this is in contrast with full parsers which are designed to be language agnostic. + + +NOTE: this module was named `base_jsonify` till version 14.0.1.5.0. diff --git a/jsonifier/security/ir.model.access.csv b/jsonifier/security/ir.model.access.csv new file mode 100644 index 000000000..dcc0a4adf --- /dev/null +++ b/jsonifier/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ir_exports_resolver,ir.exports.resolver,model_ir_exports_resolver,base.group_system,1,1,1,1 diff --git a/jsonifier/static/description/icon.png b/jsonifier/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/jsonifier/static/description/icon.png differ diff --git a/jsonifier/static/description/index.html b/jsonifier/static/description/index.html new file mode 100644 index 000000000..b3f2ecf65 --- /dev/null +++ b/jsonifier/static/description/index.html @@ -0,0 +1,555 @@ + + + + + + +JSONifier + + + +
+

JSONifier

+ + +

Beta License: LGPL-3 OCA/server-tools Translate me on Weblate Try me on Runbot

+

This module adds a ‘jsonify’ method to every model of the ORM. +It works on the current recordset and requires a single argument ‘parser’ +that specify the field to extract.

+

Example of a simple parser:

+
+parser = [
+    'name',
+    'number',
+    'create_date',
+    ('partner_id', ['id', 'display_name', 'ref'])
+    ('line_id', ['id', ('product_id', ['name']), 'price_unit'])
+]
+
+

In order to be consistent with the Odoo API the jsonify method always +returns a list of objects even if there is only one element in the recordset.

+

By default the key into the JSON is the name of the field extracted +from the model. If you need to specify an alternate name to use as key, you +can define your mapping as follow into the parser definition:

+
+parser = [
+    'field_name:json_key'
+]
+
+
+parser = [
+    'name',
+    'number',
+    'create_date:creationDate',
+    ('partner_id:partners', ['id', 'display_name', 'ref'])
+    ('line_id:lines', ['id', ('product_id', ['name']), 'price_unit'])
+]
+
+

If you need to parse the value of a field in a custom way, +you can pass a callable or the name of a method on the model:

+
+parser = [
+    ('name', "jsonify_name")  # method name
+    ('number', lambda rec, field_name: rec[field_name] * 2))  # callable
+]
+
+

Also the module provide a method “get_json_parser” on the ir.exports object +that generate a parser from an ir.exports configuration.

+

Further features are available for advanced uses. +It defines a simple “resolver” model that has a “python_code” field and a resolve +function so that arbitrary functions can be configured to transform fields, +or process the resulting dictionary. +It is also to specify a lang to extract the translation of any given field.

+

To use these features, a full parser follows the following structure:

+
+parser = {
+    "resolver": 3,
+    "language_agnostic": True,
+    "langs": {
+        False: [
+            {'name': 'description'},
+            {'name': 'number', 'resolver': 5},
+            ({'name': 'partner_id', 'target': 'partner'}, [{'name': 'display_name'}])
+        ],
+        'fr_FR': [
+            {'name': 'description', 'target': 'descriptions_fr'},
+            ({'name': 'partner_id', 'target': 'partner'}, [{'name': 'description', 'target': 'description_fr'}])
+        ],
+    }
+}
+
+

One would get a result having this structure (note that the translated fields are merged in the same dictionary):

+
+exported_json == {
+    "description": "English description",
+    "description_fr": "French description, voilà",
+    "number": 42,
+    "partner": {
+        "display_name": "partner name",
+        "description_fr": "French description of that partner",
+    },
+}
+
+

Note that a resolver can be passed either as a recordset or as an id, so as to be fully serializable. +A slightly simpler version in case the translation of fields is not needed, +but other features like custom resolvers are:

+
+parser = {
+    "resolver": 3,
+    "fields": [
+            {'name': 'description'},
+            {'name': 'number', 'resolver': 5},
+            ({'name': 'partner_id', 'target': 'partners'}, [{'name': 'display_name'}]),
+    ],
+}
+
+

By passing the fields key instead of langs, we have essentially the same behaviour as simple parsers, +with the added benefit of being able to use resolvers.

+

Standard use-cases of resolvers are: +- give field-specific defaults (e.g. “” instead of None) +- cast a field type (e.g. int()) +- alias a particular field for a specific export +- …

+

A simple parser is simply translated into a full parser at export.

+

If the global resolver is given, then the json_dict goes through:

+
+resolver.resolve(dict, record)
+
+

Which allows to add external data from the context or transform the dictionary +if necessary. Similarly if given for a field the resolver evaluates the result.

+

It is possible for a target to have a marshaller by ending the target with ‘=list’: +in that case the result is put into a list.

+
+parser = {
+    fields: [
+        {'name': 'name'},
+        {'name': 'field_1', 'target': 'customTags=list'},
+        {'name': 'field_2', 'target': 'customTags=list'},
+    ]
+}
+
+

Would result in the following JSON structure:

+
+{
+    'name': 'record_name',
+    'customTags': ['field_1_value', 'field_2_value'],
+}
+
+

The intended use-case is to be compatible with APIs that require all translated +parameters to be exported simultaneously, and ask for custom properties to be +put in a sub-dictionary. +Since it is often the case that some of these requirements are optional, +new requirements could be met without needing to add field or change any code.

+

Note that the export values with the simple parser depends on the record’s lang; +this is in contrast with full parsers which are designed to be language agnostic.

+

NOTE: this module was named base_jsonify till version 14.0.1.5.0.

+

Table of contents

+ +
+

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

+
    +
  • Akretion
  • +
  • ACSONE
  • +
  • Camptocamp
  • +
+
+
+

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/server-tools project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/jsonifier/tests/__init__.py b/jsonifier/tests/__init__.py new file mode 100644 index 000000000..3402bb152 --- /dev/null +++ b/jsonifier/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_get_parser +from . import test_helpers +from . import test_ir_exports_line diff --git a/jsonifier/tests/test_get_parser.py b/jsonifier/tests/test_get_parser.py new file mode 100644 index 000000000..9b608bd29 --- /dev/null +++ b/jsonifier/tests/test_get_parser.py @@ -0,0 +1,341 @@ +# Copyright 2017 ACSONE SA/NV +# 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 +from odoo.tests.common import TransactionCase + +from ..models.utils import convert_simple_to_full_parser + + +def jsonify_custom(self, field_name): + return "yeah!" + + +class TestParser(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # disable tracking test suite wise + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.env.user.tz = "Europe/Brussels" + cls.partner = cls.env["res.partner"].create( + { + "name": "Akretion", + "country_id": cls.env.ref("base.fr").id, + "lang": "en_US", # default + "category_id": [(0, 0, {"name": "Inovator"})], + "child_ids": [ + ( + 0, + 0, + { + "name": "Sebatien Beau", + "country_id": cls.env.ref("base.fr").id, + }, + ) + ], + "date": fields.Date.from_string("2019-10-31"), + } + ) + Langs = cls.env["res.lang"].with_context(active_test=False) + cls.lang = Langs.search([("code", "=", "fr_FR")]) + cls.lang.active = True + category = cls.env["res.partner.category"].create({"name": "name"}) + cls.translated_target = "name_{}".format(cls.lang.code) + category.with_context(lang=cls.lang.code).write({"name": cls.translated_target}) + cls.global_resolver = cls.env["ir.exports.resolver"].create( + {"python_code": "value['X'] = 'X'; result = value", "type": "global"} + ) + cls.resolver = cls.env["ir.exports.resolver"].create( + {"python_code": "result = value + '_pidgin'", "type": "field"} + ) + cls.category_export = cls.env["ir.exports"].create( + { + "global_resolver_id": cls.global_resolver.id, + "language_agnostic": True, + "export_fields": [ + (0, 0, {"name": "name"}), + ( + 0, + 0, + { + "name": "name", + "target": "name:{}".format(cls.translated_target), + "lang_id": cls.lang.id, + }, + ), + ( + 0, + 0, + { + "name": "name", + "target": "name:name_resolved", + "resolver_id": cls.resolver.id, + }, + ), + ], + } + ) + cls.category = category.with_context(lang=None) + cls.category_lang = category.with_context(lang=cls.lang.code) + + def test_getting_parser(self): + expected_parser = [ + "name", + "active", + "partner_latitude", + "color", + ("category_id", ["name"]), + ("country_id", ["name", "code"]), + ( + "child_ids", + [ + "name", + "id", + "email", + ("country_id", ["name", "code"]), + ("child_ids", ["name"]), + ], + ), + "lang", + "comment", + ] + + exporter = self.env.ref("jsonifier.ir_exp_partner") + parser = exporter.get_json_parser() + expected_full_parser = convert_simple_to_full_parser(expected_parser) + self.assertEqual(parser, expected_full_parser) + + # modify an ir.exports_line to put a target for a field + self.env.ref("jsonifier.category_id_name").write( + {"target": "category_id:category/name"} + ) + expected_parser[4] = ("category_id:category", ["name"]) + parser = exporter.get_json_parser() + expected_full_parser = convert_simple_to_full_parser(expected_parser) + self.assertEqual(parser, expected_full_parser) + + def test_json_export(self): + # Enforces TZ to validate the serialization result of a Datetime + parser = [ + "lang", + "comment", + "partner_latitude", + "name", + "color", + ( + "child_ids:children", + [ + ("child_ids:children", ["name"]), + "email", + ("country_id:country", ["code", "name"]), + "name", + "id", + ], + ), + ("country_id:country", ["code", "name"]), + "active", + ("category_id", ["name"]), + "create_date", + "date", + ] + # put our own create date to ease tests + self.env.cr.execute( + "update res_partner set create_date=%s where id=%s", + ("2019-10-31 14:39:49", self.partner.id), + ) + expected_json = { + "lang": "en_US", + "comment": None, + "partner_latitude": 0.0, + "name": "Akretion", + "color": 0, + "country": {"code": "FR", "name": "France"}, + "active": True, + "category_id": [{"name": "Inovator"}], + "children": [ + { + "id": self.partner.child_ids.id, + "country": {"code": "FR", "name": "France"}, + "children": [], + "name": "Sebatien Beau", + "email": None, + } + ], + "create_date": "2019-10-31T14:39:49", + "date": "2019-10-31", + } + json_partner = self.partner.jsonify(parser) + + self.assertDictEqual(json_partner[0], expected_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 + # This value is not the expected one into the json + self.partner.write({"child_ids": [(6, 0, [])], "active": False, "lang": False}) + json_partner = self.partner.jsonify(parser) + expected_json["active"] = False + expected_json["lang"] = None + expected_json["children"] = [] + self.assertDictEqual(json_partner[0], expected_json) + + def test_one(self): + parser = [ + "name", + ] + expected_json = { + "name": "Akretion", + } + json_partner = self.partner.jsonify(parser, one=True) + self.assertDictEqual(json_partner, expected_json) + # cannot call on multiple records + with self.assertRaises(ValueError) as err: + self.env["res.partner"].search([]).jsonify(parser, one=True) + self.assertIn("Expected singleton", str(err.exception)) + + def test_json_export_callable_parser(self): + self.partner.__class__.jsonify_custom = jsonify_custom + parser = [ + # callable subparser + ("name", lambda rec, fname: rec[fname] + " rocks!"), + ("name:custom", "jsonify_custom"), + ] + expected_json = { + "name": "Akretion rocks!", + "custom": "yeah!", + } + json_partner = self.partner.jsonify(parser) + self.assertDictEqual(json_partner[0], expected_json) + del self.partner.__class__.jsonify_custom + + def test_full_parser(self): + parser = self.category_export.get_json_parser() + json = self.category.jsonify(parser)[0] + json_fr = self.category_lang.jsonify(parser)[0] + + self.assertEqual( + json, json_fr + ) # starting from different languages should not change anything + self.assertEqual(json[self.translated_target], self.translated_target) + self.assertEqual(json["name_resolved"], "name_pidgin") # 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"] + json = self.category.jsonify(parser)[0] + json_fr = self.category_lang.jsonify(parser)[0] + + self.assertEqual(json["name"], "name") + self.assertEqual(json_fr["name"], self.translated_target) + + def test_simple_star_target_and_field_resolver(self): + """The simple parser result should depend on the context language.""" + code = ( + "is_number = field_type in ('integer', 'float');" + "ftype = 'NUMBER' if is_number else 'TEXT';" + "value = value if is_number else str(value);" + "result = {'Key': name, 'Value': value, 'Type': ftype, 'IsPublic': True}" + ) + resolver = self.env["ir.exports.resolver"].create({"python_code": code}) + lang_parser = [ + {"target": "customTags=list", "name": "name", "resolver": resolver}, + {"target": "customTags=list", "name": "id", "resolver": resolver}, + ] + parser = {"language_agnostic": True, "langs": {False: lang_parser}} + expected_json = { + "customTags": [ + {"Value": "name", "Key": "name", "Type": "TEXT", "IsPublic": True}, + { + "Value": self.category.id, + "Key": "id", + "Type": "NUMBER", + "IsPublic": True, + }, + ] + } + + json = self.category.jsonify(parser)[0] + self.assertEqual(json, expected_json) + + def test_simple_export_with_function(self): + self.category.__class__.jsonify_custom = jsonify_custom + export = self.env["ir.exports"].create( + { + "export_fields": [ + (0, 0, {"name": "name", "instance_method_name": "jsonify_custom"}), + ], + } + ) + + json = self.category.jsonify(export.get_json_parser())[0] + self.assertEqual(json, {"name": "yeah!"}) + + def test_export_relational_display_names(self): + """If we export a relational, we get its display_name in the json.""" + parser = [ + "state_id", + "country_id", + "category_id", + "user_ids", + ] + expected_json = { + "state_id": None, + "country_id": "France", + "category_id": ["Inovator"], + "user_ids": [], + } + + json_partner = self.partner.jsonify(parser, one=True) + + self.assertDictEqual(json_partner, expected_json) + + def test_export_reference_display_names(self): + """Reference work the same as relational""" + menu = self.env.ref("base.menu_action_res_users") + + json_menu = menu.jsonify(["action"], one=True) + + self.assertDictEqual(json_menu, {"action": "Users"}) + + def test_bad_parsers_strict(self): + rec = self.category.with_context(jsonify_record_strict=True) + bad_field_name = ["Name"] + with self.assertRaises(KeyError): + rec.jsonify(bad_field_name, one=True) + + bad_function_name = {"fields": [{"name": "name", "function": "notafunction"}]} + with self.assertRaises(UserError): + rec.jsonify(bad_function_name, one=True) + + bad_subparser = {"fields": [({"name": "name"}, [{"name": "subparser_name"}])]} + with self.assertRaises(UserError): + rec.jsonify(bad_subparser, one=True) + + 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. + tools.config["test_enable"] = False + + bad_field_name = ["Name"] + with mock.patch(logger_patch_path) as mocked_logger: + rec.jsonify(bad_field_name, one=True) + mocked_logger.assert_called() + + bad_function_name = {"fields": [{"name": "name", "function": "notafunction"}]} + with mock.patch(logger_patch_path) as mocked_logger: + rec.jsonify(bad_function_name, one=True) + mocked_logger.assert_called() + + bad_subparser = {"fields": [({"name": "name"}, [{"name": "subparser_name"}])]} + with mock.patch(logger_patch_path) as mocked_logger: + rec.jsonify(bad_subparser, one=True) + mocked_logger.assert_called() + + tools.config["test_enable"] = True diff --git a/jsonifier/tests/test_helpers.py b/jsonifier/tests/test_helpers.py new file mode 100644 index 000000000..6c57a4a23 --- /dev/null +++ b/jsonifier/tests/test_helpers.py @@ -0,0 +1,45 @@ +# Copyright 2021 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + + +class TestJsonifyHelpers(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.partner = cls.env["res.partner"].create( + { + "name": "My Partner", + } + ) + cls.children = cls.env["res.partner"].create( + [ + {"parent_id": cls.partner.id, "name": "Child 1"}, + {"parent_id": cls.partner.id, "name": "Child 2"}, + ] + ) + + def test_helper_m2o_to_id(self): + child = self.children[0] + self.assertEqual( + child._jsonify_m2o_to_id("parent_id"), + child.parent_id.id, + ) + + def test_helper_m2m_to_ids(self): + self.assertEqual( + self.partner._jsonify_x2m_to_ids("child_ids"), + self.partner.child_ids.ids, + ) + + def test_helper_format_duration(self): + # partner_latitude is not intended for this, but it's a float field in core + # any float field does the trick here + self.partner.partner_latitude = 15.5 + self.assertEqual( + self.partner._jsonify_format_duration("partner_latitude"), + "15:30", + ) diff --git a/jsonifier/tests/test_ir_exports_line.py b/jsonifier/tests/test_ir_exports_line.py new file mode 100644 index 000000000..c83e1f965 --- /dev/null +++ b/jsonifier/tests/test_ir_exports_line.py @@ -0,0 +1,68 @@ +# Copyright 2017 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestIrExportsLine(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ir_export = cls.env.ref("jsonifier.ir_exp_partner") + + def test_target_constrains(self): + ir_export_lines_model = self.env["ir.exports.line"] + with self.assertRaises(ValidationError): + # The field into the name must be also into the target + ir_export_lines_model.create( + { + "export_id": self.ir_export.id, + "name": "name", + "target": "toto:my_target", + } + ) + with self.assertRaises(ValidationError): + # The hierarchy into the target must be the same as the one into + # the name + ir_export_lines_model.create( + { + "export_id": self.ir_export.id, + "name": "child_ids/child_ids/name", + "target": "child_ids:children/name", + } + ) + with self.assertRaises(ValidationError): + # The hierarchy into the target must be the same as the one into + # the name and must contains the same fields as into the name + ir_export_lines_model.create( + { + "export_id": self.ir_export.id, + "name": "child_ids/child_ids/name", + "target": "child_ids:children/category_id:category/name", + } + ) + line = ir_export_lines_model.create( + { + "export_id": self.ir_export.id, + "name": "child_ids/child_ids/name", + "target": "child_ids:children/child_ids:children/name", + } + ) + self.assertTrue(line) + + def test_resolver_function_constrains(self): + resolver = self.env["ir.exports.resolver"].create( + {"python_code": "result = value", "type": "field"} + ) + ir_export_lines_model = self.env["ir.exports.line"] + with self.assertRaises(ValidationError): + # the callable should be an existing model function, but it's not checked + ir_export_lines_model.create( + { + "export_id": self.ir_export.id, + "name": "name", + "resolver_id": resolver.id, + "instance_method_name": "function_name", + } + ) diff --git a/jsonifier/views/ir_exports_resolver_view.xml b/jsonifier/views/ir_exports_resolver_view.xml new file mode 100644 index 000000000..1f18bcaf0 --- /dev/null +++ b/jsonifier/views/ir_exports_resolver_view.xml @@ -0,0 +1,26 @@ + + + + ir.exports.resolver + 50 + +
+ + + + + +
+
+
+ + Custom Export Resolvers + ir.exports.resolver + tree,form + + +
diff --git a/jsonifier/views/ir_exports_view.xml b/jsonifier/views/ir_exports_view.xml new file mode 100644 index 000000000..ddfb5a152 --- /dev/null +++ b/jsonifier/views/ir_exports_view.xml @@ -0,0 +1,38 @@ + + + + ir.exports + 50 + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ + Export Fields + ir.exports + tree,form + + +
diff --git a/setup/jsonifier/odoo/addons/jsonifier b/setup/jsonifier/odoo/addons/jsonifier new file mode 120000 index 000000000..a7a26b93f --- /dev/null +++ b/setup/jsonifier/odoo/addons/jsonifier @@ -0,0 +1 @@ +../../../../jsonifier \ No newline at end of file diff --git a/setup/jsonifier/setup.py b/setup/jsonifier/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/jsonifier/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)