diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c0630693cf15..8e5f26d74516 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,6 +3,7 @@ exclude: | # NOT INSTALLABLE ADDONS # END NOT INSTALLABLE ADDONS ^l10n_br_nfe_spec/models/v4_0/| # (don't reformat generated code) + ^spec_driven_model/tests/| # (tests include generated code) # Files and folders generated by bots, to avoid loops ^setup/|/static/description/index\.html$| # We don't want to mess with tool-generated files diff --git a/oca_dependencies.txt b/oca_dependencies.txt index f5a0a2fe4f3d..84725da2312c 100644 --- a/oca_dependencies.txt +++ b/oca_dependencies.txt @@ -1,3 +1,4 @@ web account-payment account-reconcile +odoo-test-helper diff --git a/setup/spec_driven_model/odoo/addons/spec_driven_model b/setup/spec_driven_model/odoo/addons/spec_driven_model new file mode 120000 index 000000000000..6f8d86bc4929 --- /dev/null +++ b/setup/spec_driven_model/odoo/addons/spec_driven_model @@ -0,0 +1 @@ +../../../../spec_driven_model \ No newline at end of file diff --git a/setup/spec_driven_model/setup.py b/setup/spec_driven_model/setup.py new file mode 100644 index 000000000000..28c57bb64031 --- /dev/null +++ b/setup/spec_driven_model/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/spec_driven_model/README.rst b/spec_driven_model/README.rst new file mode 100644 index 000000000000..e636ee88cb23 --- /dev/null +++ b/spec_driven_model/README.rst @@ -0,0 +1,160 @@ +================= +Spec Driven Model +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:7a60762120de2d0aa75c133393ab92467fdfd07bc140b199f292a250b0ffe461 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fl10n--brazil-lightgray.png?logo=github + :target: https://github.com/OCA/l10n-brazil/tree/16.0/spec_driven_model + :alt: OCA/l10n-brazil +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/l10n-brazil-16-0/l10n-brazil-16-0-spec_driven_model + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/l10n-brazil&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Intro +~~~~~ + +This module is a databinding framework for Odoo and XML data: it allows to go from XML to Odoo objects back and forth. This module started with the `GenerateDS `_ pure Python databinding framework and is now being migrated to xsdata. So a good starting point is to read `the xsdata documentation here `_ + +But what if instead of only generating Python structures from XML files you could actually generate full blown Odoo objects or serialize Odoo objects back to XML? This is what this module is for! + +First you should generate xsdata Python binding libraries you would generate for your specific XSD grammar, the Brazilian Electronic Invoicing for instance, or UBL. + +Second you should generate Odoo abstract mixins for all these pure Python bindings. This can be achieved using `xsdata-odoo `_. An example is OCA/l10n-brazil/l10n_br_nfe_spec for the Brazilian Electronic Invoicing. + +SpecModel +~~~~~~~~~ + +Now that you have generated these Odoo abstract bindings you should tell Odoo how to use them. For instance you may want that your electronic invoice abstract model matches the Odoo `res.partner` object. This is fairly easy, you mostly need to define an override like:: + + + from odoo.addons.spec_driven_model.models import spec_models + + + class ResPartner(spec_models.SpecModel): + _inherit = [ + 'res.partner', + 'partner.binding.mixin', + ] + +Notice you should inherit from `spec_models.SpecModel` and not the usual `models.Model`. + +**Field mapping**: You can then define two ways mapping between fields by overriding fields from Odoo or from the binding and using `_compute=` , `_inverse=` or simply `related=`. + +**Relational fields**: simple fields are easily mapped this way. However what about relational fields? In your XSD schema, your electronic invoice is related to the `partner.binding.mixin` not to an Odoo `res.partner`. Don't worry, when `SpecModel` classes are instanciated for all relational fields, we look if their comodel have been injected into some existing Odoo model and if so we remap them to the proper Odoo model. + +**Field prefixes**: to avoid field collision between the Odoo fields and the XSD fields, the XSD fields are prefixed with the name of the schema and a few digits representing the schema version (typically 2 digits). So if your schema get a minor version upgrade, the same fields and classes are used. For a major upgrade however new fields and classes may be used so data of several major versions could co-exist inside your Odoo database. + + +StackedModel +~~~~~~~~~~~~ + +Sadly real life XML is a bit more complex than that. Often XML structures are deeply nested just because it makes it easier for XSD schemas to validate them! for instance an electronic invoice line can be a nested structure with lots of tax details and product details. In a relational model like Odoo however you often want flatter data structures. This is where `StackedModel` comes to the rescue! It inherits from `SpecModel` and when you inherit from `StackedModel` you can inherit from all the generated mixins corresponding to the nested XML tags below some tag (here `invoice.line.binding.mixin`). All the fields corresponding to these XML tag attributes will be collected in your model and the XML parsing and serialization will happen as expected:: + + + from odoo.addons.spec_driven_model.models import spec_models + + + class InvoiceLine(spec_models.StackedModel): + _inherit = [ + 'account.move.line', + 'invoice.line.binding.mixin', + ] + _stacked = 'invoice.line.binding.mixin' + +All many2one fields that are required in the XSD (xsd_required=True) will get their model stacked automatically and recursively. You can force non required many2one fields to be stacked using the `_force_stack_paths` attribute. On the contrary, you can avoid some required many2one fields to be stacked using the `stack_skip` attribute. + + +Hooks +~~~~~ + +Because XSD schemas can define lot's of different models, spec_driven_model comes with handy hooks that will automatically make all XSD mixins turn into concrete Odoo model (eg with a table) if you didn't inject them into existing Odoo models. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +See my detailed OCA Days explanations here: +https://www.youtube.com/watch?v=6gFOe7Wh8uA + +You are also encouraged to look at the tests directory which features a full blown example from the famous PurchaseOrder.xsd from Microsoft tutorials. + +Known issues / Roadmap +====================== + +Migrate from generateDS to xsdata; see the xsdata Pull Requests in the repo. + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Akretion + +Contributors +~~~~~~~~~~~~ + +* `AKRETION `_: + + * Raphaël Valyi + +* `KMEE `_: + + * Gabriel Cardoso de Faria + +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. + +.. |maintainer-rvalyi| image:: https://github.com/rvalyi.png?size=40px + :target: https://github.com/rvalyi + :alt: rvalyi + +Current `maintainer `__: + +|maintainer-rvalyi| + +This module is part of the `OCA/l10n-brazil `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/spec_driven_model/__init__.py b/spec_driven_model/__init__.py new file mode 100644 index 000000000000..0650744f6bc6 --- /dev/null +++ b/spec_driven_model/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/spec_driven_model/__manifest__.py b/spec_driven_model/__manifest__.py new file mode 100644 index 000000000000..9c2dc227132a --- /dev/null +++ b/spec_driven_model/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2019 Akretion +# License LGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Spec Driven Model", + "summary": """ + Tools for specifications driven mixins (from xsd for instance)""", + "version": "16.0.1.0.0", + "maintainers": ["rvalyi"], + "license": "LGPL-3", + "author": "Akretion,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/l10n-brazil", + "depends": [], + "data": [], + "demo": [], + "development_status": "Beta", +} diff --git a/spec_driven_model/hooks.py b/spec_driven_model/hooks.py new file mode 100644 index 000000000000..9add12b49af5 --- /dev/null +++ b/spec_driven_model/hooks.py @@ -0,0 +1,192 @@ +# Copyright (C) 2019-TODAY - Raphaël Valyi Akretion +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import inspect +import logging +import sys + +from odoo import SUPERUSER_ID, api, models + +from .models.spec_models import SpecModel, StackedModel + +_logger = logging.getLogger(__name__) + + +def post_init_hook(cr, registry, module_name, spec_module): + """ + Automatically generate access rules for spec models + """ + env = api.Environment(cr, SUPERUSER_ID, {}) + remaining_models = get_remaining_spec_models(cr, registry, module_name, spec_module) + fields = [ + "id", + "name", + "model_id/id", + "group_id/id", + "perm_read", + "perm_write", + "perm_create", + "perm_unlink", + ] + access_data = [] + for model in remaining_models: + underline_name = model.replace(".", "_") + model_id = "%s_spec.model_%s" % ( + module_name, + underline_name, + ) + access_data.append( + [ + "access_%s_user" % (underline_name,), + underline_name, + model_id, + "%s.group_user" % (module_name,), + "1", + "0", + "0", + "0", + ] + ) + access_data.append( + [ + "access_%s_manager" % (underline_name,), + underline_name, + model_id, + "%s.group_manager" % (module_name,), + "1", + "1", + "1", + "1", + ] + ) + env["ir.model.access"].load(fields, access_data) + + +def get_remaining_spec_models(cr, registry, module_name, spec_module): + """ + Figure out the list of spec models not injected into existing + Odoo models. + """ + cr.execute( + """select ir_model.model from ir_model_data + join ir_model on res_id=ir_model.id + where ir_model_data.model='ir.model' + and module=%s;""", + (module_name,), + ) + module_models = [ + i[0] + for i in cr.fetchall() + if registry.get(i[0]) and not registry[i[0]]._abstract + ] + + injected_models = set() + for model in module_models: + base_class = registry[model] + # 1st classic Odoo classes + if hasattr(base_class, "_inherit"): + injected_models.add(base_class._name) + for cls in base_class.mro(): + if hasattr(cls, "_inherit") and cls._inherit: + if isinstance(cls._inherit, list): + inherit_list = cls._inherit + else: + inherit_list = [cls._inherit] + for inherit in inherit_list: + if inherit.startswith("spec.mixin."): + injected_models.add(cls._name) + + # visit_stack will now need the associated spec classes + injected_classes = set() + remaining_models = set() + + for m in injected_models: + c = SpecModel._odoo_name_to_class(m, spec_module) + if c is not None: + injected_classes.add(c) + + for model in module_models: + base_class = registry[model] + # 2nd StackedModel classes, that we will visit + if hasattr(base_class, "_stacked"): + node = SpecModel._odoo_name_to_class(base_class._stacked, spec_module) + + env = api.Environment(cr, SUPERUSER_ID, {}) + for ( + _kind, + klass, + _path, + _field_path, + _child_concrete, + ) in base_class._visit_stack(env, node): + injected_classes.add(klass) + + all_spec_models = { + c._name + for name, c in inspect.getmembers(sys.modules[spec_module], inspect.isclass) + if c._name in registry + } + + remaining_models = remaining_models.union( + {i for i in all_spec_models if i not in [c._name for c in injected_classes]} + ) + return remaining_models + + +def register_hook(env, module_name, spec_module, force=False): + """ + Called by Model#_register_hook once all modules are loaded. + Here we take all spec models that are not injected in existing concrete + Odoo models and we make them concrete automatically with + their _auto_init method that will create their SQL DDL structure. + """ + load_key = "_%s_loaded" % (spec_module,) + if hasattr(env.registry, load_key) and not force: # already done for registry + return + setattr(env.registry, load_key, True) + + remaining_models = get_remaining_spec_models( + env.cr, env.registry, module_name, spec_module + ) + for name in remaining_models: + spec_class = StackedModel._odoo_name_to_class(name, spec_module) + spec_class._module = "fiscal" # TODO use python_module ? + fields = env[spec_class._name].fields_get_keys() + rec_name = next( + filter( + lambda x: ( + x.startswith(env[spec_class._name]._field_prefix) + and "_choice" not in x + ), + fields, + ) + ) + inherit = list(spec_class._inherit) + ["spec.mixin"] + c = type( + name, + (SpecModel, spec_class), + { + "_name": name, + "_inherit": inherit, + "_original_module": "fiscal", + "_odoo_module": module_name, + "_spec_module": spec_module, + "_rec_name": rec_name, + "_module": module_name, + }, + ) + models.MetaModel.module_to_models[module_name] += [c] + + # now we init these models properly + # a bit like odoo.modules.loading#load_module_graph would do. + c._build_model(env.registry, env.cr) + + env[name]._prepare_setup() + env[name]._setup_base() + env[name]._setup_fields() + env[name]._setup_complete() + + hook_key = "_%s_need_hook" % (module_name,) + if hasattr(env.registry, hook_key) and getattr(env.registry, hook_key): + env.registry.init_models(env.cr, remaining_models, {"module": module_name}) + setattr(env.registry, hook_key, False) diff --git a/spec_driven_model/i18n/pt_BR.po b/spec_driven_model/i18n/pt_BR.po new file mode 100644 index 000000000000..288c45b45947 --- /dev/null +++ b/spec_driven_model/i18n/pt_BR.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * spec_driven_model +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-01-02 20:00+0000\n" +"Last-Translator: Marcel Savegnago \n" +"Language-Team: none\n" +"Language: pt_BR\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" +"X-Generator: Weblate 4.17\n" + +#. module: spec_driven_model +#: code:addons/spec_driven_model/models/spec_models.py:0 +#, python-format +msgid "Abrir..." +msgstr "Abrir..." + +#. module: spec_driven_model +#: model:ir.model.fields,field_description:spec_driven_model.field_spec_mixin__display_name +msgid "Display Name" +msgstr "Nome Exibido" + +#. module: spec_driven_model +#: model:ir.model.fields,field_description:spec_driven_model.field_spec_mixin__id +msgid "ID" +msgstr "ID" + +#. module: spec_driven_model +#: model:ir.model.fields,field_description:spec_driven_model.field_spec_mixin____last_update +msgid "Last Modified on" +msgstr "Última Atualização em" + +#. module: spec_driven_model +#: model:ir.model,name:spec_driven_model.model_spec_mixin +msgid "root abstract model meant for xsd generated fiscal models" +msgstr "modelo abstrato raiz destinado a modelos fiscais gerados por xsd" diff --git a/spec_driven_model/i18n/spec_driven_model.pot b/spec_driven_model/i18n/spec_driven_model.pot new file mode 100644 index 000000000000..80f0b61a2e58 --- /dev/null +++ b/spec_driven_model/i18n/spec_driven_model.pot @@ -0,0 +1,40 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * spec_driven_model +# +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: spec_driven_model +#: code:addons/spec_driven_model/models/spec_models.py:0 +#, python-format +msgid "Abrir..." +msgstr "" + +#. module: spec_driven_model +#: model:ir.model.fields,field_description:spec_driven_model.field_spec_mixin__display_name +msgid "Display Name" +msgstr "" + +#. module: spec_driven_model +#: model:ir.model.fields,field_description:spec_driven_model.field_spec_mixin__id +msgid "ID" +msgstr "" + +#. module: spec_driven_model +#: model:ir.model.fields,field_description:spec_driven_model.field_spec_mixin____last_update +msgid "Last Modified on" +msgstr "" + +#. module: spec_driven_model +#: model:ir.model,name:spec_driven_model.model_spec_mixin +msgid "root abstract model meant for xsd generated fiscal models" +msgstr "" diff --git a/spec_driven_model/models/__init__.py b/spec_driven_model/models/__init__.py new file mode 100644 index 000000000000..f3a0bb2ff92f --- /dev/null +++ b/spec_driven_model/models/__init__.py @@ -0,0 +1,6 @@ +from . import spec_export +from . import spec_import +from . import spec_mixin + +# from . import spec_view +from . import spec_models diff --git a/spec_driven_model/models/spec_export.py b/spec_driven_model/models/spec_export.py new file mode 100644 index 000000000000..52911fd4ad91 --- /dev/null +++ b/spec_driven_model/models/spec_export.py @@ -0,0 +1,265 @@ +# Copyright 2019 KMEE +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +import logging +import sys +from io import StringIO + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class SpecMixinExport(models.AbstractModel): + _name = "spec.mixin_export" + _description = "a mixin providing serialization features" + + @api.model + def _get_binding_class(self, class_obj): + binding_module = sys.modules[self._binding_module] + for attr in class_obj._binding_type.split("."): + binding_module = getattr(binding_module, attr) + return binding_module + + @api.model + def _get_model_classes(self): + classes = [getattr(x, "_name", None) for x in type(self).mro()] + return classes + + @api.model + def _get_spec_classes(self, classes=False): + if not classes: + classes = self._get_model_classes() + spec_classes = [] + for c in set(classes): + if c is None: + continue + if not c.startswith("%s." % (self._schema_name,)): + continue + # the following filter to fields to show + # when several XSD class are injected in the same object + if self._context.get("spec_class") and c != self._context["spec_class"]: + continue + spec_classes.append(c) + return spec_classes + + @api.model + def _print_xml(self, binding_instance): + if not binding_instance: + return + output = StringIO() + binding_instance.export( + output, + 0, + pretty_print=True, + ) + output.close() + + def _export_fields(self, xsd_fields, class_obj, export_dict): + """ + Iterate over the record fields and map them in an dict of values + that will later be injected as **kwargs in the proper XML Python + binding constructors. Hence the value can either be simple values or + sub binding instances already properly instanciated. + + This method implements a dynamic dispatch checking if there is any + method called _export_fields_CLASS_NAME to update the xsd_fields + and export_dict variables, this way we allow controlling the + flow of fields to export or injecting specific values ​​in the + field export. + """ + self.ensure_one() + binding_class = self._get_binding_class(class_obj) + binding_class_spec = binding_class.__dataclass_fields__ + + class_name = class_obj._name.replace(".", "_") + export_method_name = "_export_fields_%s" % class_name + if hasattr(self, export_method_name): + xsd_fields = [i for i in xsd_fields] + export_method = getattr(self, export_method_name) + export_method(xsd_fields, class_obj, export_dict) + + for xsd_field in xsd_fields: + if not xsd_field: + continue + if ( + not self._fields.get(xsd_field) + ) and xsd_field not in self._stacking_points.keys(): + continue + field_spec_name = xsd_field.replace(class_obj._field_prefix, "") + field_spec = False + for fname, fspec in binding_class_spec.items(): + if fspec.metadata.get("name", {}) == field_spec_name: + field_spec_name = fname + if field_spec_name == fname: + field_spec = fspec + if field_spec and not field_spec.init: + # case of xsd fixed values, we should not try to write them + continue + + if not binding_class_spec.get(field_spec_name): + # this can happen with a o2m generated foreign key for instance + continue + field_spec = binding_class_spec[field_spec_name] + field_data = self._export_field( + xsd_field, class_obj, field_spec, export_dict.get(field_spec_name) + ) + if xsd_field in self._stacking_points.keys(): + if not field_data: + # stacked nested tags are skipped if empty + continue + elif not self[xsd_field] and not field_data: + continue + + export_dict[field_spec_name] = field_data + + def _export_field(self, xsd_field, class_obj, field_spec, export_value=None): + """ + Map a single Odoo field to a python binding value according to the + kind of field. + """ + self.ensure_one() + # TODO: Export number required fields with Zero. + field = class_obj._fields.get(xsd_field, self._stacking_points.get(xsd_field)) + xsd_required = field.xsd_required if hasattr(field, "xsd_required") else None + xsd_type = field.xsd_type if hasattr(field, "xsd_type") else None + if field.type == "many2one": + if (not self._stacking_points.get(xsd_field)) and ( + not self[xsd_field] and not xsd_required + ): + if field.comodel_name not in self._get_spec_classes(): + return False + if hasattr(field, "xsd_choice_required"): + # NOTE generateds-odoo would abusively have xsd_required=True + # already in the spec file in this case. + # In xsdata-odoo we introduced xsd_choice_required. + # Here we make the legacy code compatible with xsdata-odoo: + xsd_required = True + return self._export_many2one(xsd_field, xsd_required, class_obj) + elif self._fields[xsd_field].type == "one2many": + return self._export_one2many(xsd_field, class_obj) + elif self._fields[xsd_field].type == "datetime" and self[xsd_field]: + return self._export_datetime(xsd_field) + elif self._fields[xsd_field].type == "date" and self[xsd_field]: + return self._export_date(xsd_field) + elif ( + self._fields[xsd_field].type in ("float", "monetary") + and self[xsd_field] is not False + ): + if hasattr(field, "xsd_choice_required"): + xsd_required = True # NOTE compat, see previous NOTE + return self._export_float_monetary( + xsd_field, xsd_type, class_obj, xsd_required, export_value + ) + elif type(self[xsd_field]) is str: + return self[xsd_field].strip() + else: + return self[xsd_field] + + def _export_many2one(self, field_name, xsd_required, class_obj=None): + self.ensure_one() + if field_name in self._stacking_points.keys(): + return self._build_generateds( + class_name=self._stacking_points[field_name].comodel_name + ) + else: + return (self[field_name] or self)._build_generateds( + class_obj._fields[field_name].comodel_name + ) + + def _export_one2many(self, field_name, class_obj=None): + self.ensure_one() + relational_data = [] + for relational_field in self[field_name]: + field_data = relational_field._build_generateds( + class_obj._fields[field_name].comodel_name + ) + relational_data.append(field_data) + return relational_data + + def _export_float_monetary( + self, field_name, xsd_type, class_obj, xsd_required, export_value=None + ): + self.ensure_one() + field_data = export_value or self[field_name] + # TODO check xsd_required for all fields to export? + if not field_data and not xsd_required: + return False + if xsd_type and xsd_type.startswith("TDec"): + tdec = "".join(filter(lambda x: x.isdigit(), xsd_type))[-2:] + else: + tdec = "" + my_format = "%.{}f".format(tdec) + return str(my_format % field_data) + + def _export_date(self, field_name): + self.ensure_one() + return str(self[field_name]) + + def _export_datetime(self, field_name): + self.ensure_one() + return str( + fields.Datetime.context_timestamp( + self, fields.Datetime.from_string(self[field_name]) + ).isoformat("T") + ) + + def _build_generateds(self, class_name=False): + """ + Iterate over an Odoo record and its m2o and o2m sub-records + using a pre-order tree traversal and maps the Odoo record values + to a dict of Python binding values. + + These values will later be injected as **kwargs in the proper XML Python + binding constructors. Hence the value can either be simple values or + sub binding instances already properly instanciated. + """ + self.ensure_one() + if not class_name: + if hasattr(self, "_stacked"): + class_name = self._stacked + else: + class_name = self._name + + class_obj = self.env[class_name] + + xsd_fields = ( + i + for i in class_obj._fields + if class_obj._fields[i].name.startswith(class_obj._field_prefix) + and "_choice" not in class_obj._fields[i].name + ) + + kwargs = {} + binding_class = self._get_binding_class(class_obj) + self._export_fields(xsd_fields, class_obj, export_dict=kwargs) + if kwargs: + sliced_kwargs = { + key: kwargs.get(key) + for key in binding_class.__dataclass_fields__.keys() + if kwargs.get(key) + } + binding_instance = binding_class(**sliced_kwargs) + return binding_instance + + def export_xml(self, print_xml=True): + self.ensure_one() + result = [] + + if hasattr(self, "_stacked"): + binding_instance = self._build_generateds() + if print_xml: + self._print_xml(binding_instance) + result.append(binding_instance) + + else: + spec_classes = self._get_spec_classes() + for class_name in spec_classes: + binding_instance = self._build_generateds(class_name) + if print: + self._print_xml(binding_instance) + result.append(binding_instance) + return result + + def export_ds(self): + self.ensure_one() + return self.export_xml(print_xml=False) diff --git a/spec_driven_model/models/spec_import.py b/spec_driven_model/models/spec_import.py new file mode 100644 index 000000000000..5d1e431bd933 --- /dev/null +++ b/spec_driven_model/models/spec_import.py @@ -0,0 +1,347 @@ +# Copyright 2019-2020 Akretion - Raphael Valyi +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import dataclasses +import inspect +import logging +import re +from datetime import datetime +from enum import Enum +from typing import ForwardRef + +from odoo import api, models + +from .spec_models import SpecModel + +_logger = logging.getLogger(__name__) + + +tz_datetime = re.compile(r".*[-+]0[0-9]:00$") + + +class SpecMixinImport(models.AbstractModel): + _name = "spec.mixin_import" + _description = """ + A recursive Odoo object builder that works along with the + GenerateDS object builder from the parsed XML. + Here we take into account the concrete Odoo objects where the schema + mixins where injected and possible matcher or builder overrides. + """ + + @api.model + def build_from_binding(self, node, dry_run=False): + """ + Build an instance of an Odoo Model from a pre-populated + Python binding object. Binding object such as the ones generated using + generateDS can indeed be automatically populated from an XML file. + This build method bridges the gap to build the Odoo object. + + It uses a pre-order tree traversal of the Python bindings and for each + sub-binding (or node) it sees what is the corresponding Odoo model to map. + + Build can persist the object or just return a new instance + depending on the dry_run parameter. + + Defaults values and control options are meant to be passed in the context. + """ + model_name = SpecModel._get_concrete(self._name) or self._name + model = self.env[model_name] + attrs = model.with_context(dry_run=dry_run).build_attrs(node) + if dry_run: + return model.new(attrs) + else: + return model.create(attrs) + + @api.model + def build_attrs(self, node, path="", defaults_model=None): + """ + Build a new odoo model instance from a Python binding element or + sub-element. Iterates over the binding fields to populate the Odoo fields. + """ + vals = {} + for fname, fspec in node.__dataclass_fields__.items(): + self._build_attr(node, self._fields, vals, path, (fname, fspec)) + vals = self._prepare_import_dict(vals, defaults_model=defaults_model) + return vals + + @api.model + def _build_attr(self, node, fields, vals, path, attr): + """ + Build an Odoo field from a binding attribute. + """ + value = getattr(node, attr[0]) + if value is None or value == []: + return False + key = "%s%s" % ( + self._field_prefix, + attr[1].metadata.get("name", attr[0]), + ) + child_path = "%s.%s" % (path, key) + + # Is attr a xsd SimpleType or a ComplexType? + # with xsdata a ComplexType can have a type like: + # typing.Union[nfelib.nfe.bindings.v4_0.leiaute_nfe_v4_00.TinfRespTec, NoneType] + # or typing.Union[ForwardRef('Tnfe.InfNfe.Det.Imposto'), NoneType] + # that's why we test if the 1st Union type is a dataclass or a ForwardRef + if attr[1].type == str or ( + not isinstance(attr[1].type.__args__[0], ForwardRef) + and not dataclasses.is_dataclass(attr[1].type.__args__[0]) + ): + # SimpleType + if isinstance(value, Enum): + value = value.value + if fields.get(key) and fields[key].type == "datetime": + if "T" in value: + if tz_datetime.match(value): + old_value = value + value = old_value[:19] + # TODO see python3/pysped/xml_sped/base.py#L692 + value = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S") + + self._build_string_not_simple_type(key, vals, value, node) + + else: + if str(attr[1].type).startswith("typing.List") or "ForwardRef" in str( + attr[1].type + ): # o2m + binding_type = attr[1].type.__args__[0].__forward_arg__ + else: + binding_type = attr[1].type.__args__[0].__name__ + + # ComplexType + if fields.get(key) and fields[key].related: + if fields[key].readonly and fields[key].type == "many2one": + return False # ex: don't import NFe infRespTec + # example: company.nfe40_enderEmit related on partner_id + # then we need to set partner_id, not nfe40_enderEmit + if isinstance(fields[key].related, list): + key = fields[key].related[-1] # -1 works with _inherits + else: + key = fields[key].related + comodel_name = fields[key].comodel_name + else: + clean_type = binding_type.lower() + comodel_name = "%s.%s.%s" % ( + self._schema_name, + self._schema_version.replace(".", "")[0:2], + clean_type.split(".")[-1], + ) + + comodel = self.get_concrete_model(comodel_name) + if comodel is None: # example skip ICMS100 class + return + if str(attr[1].type).startswith("typing.List"): + # o2m + lines = [] + for line in [li for li in value if li]: + line_vals = comodel.build_attrs( + line, path=child_path, defaults_model=comodel + ) + lines.append((0, 0, line_vals)) + vals[key] = lines + else: + # m2o + comodel_vals = comodel.build_attrs(value, path=child_path) + child_defaults = self._extract_related_values(vals, key) + + comodel_vals.update(child_defaults) + # FIXME comodel._build_many2one + self._build_many2one( + comodel, vals, comodel_vals, key, value, child_path + ) + + @api.model + def _build_string_not_simple_type(self, key, vals, value, node): + vals[key] = value + + @api.model + def _build_many2one(self, comodel, vals, comodel_vals, key, value, path): + if comodel._name == self._name: + # stacked m2o + vals.update(comodel_vals) + else: + vals[key] = comodel.match_or_create_m2o(comodel_vals, vals) + + @api.model + def get_concrete_model(self, comodel_name): + "Lookup for concrete models where abstract schema mixins were injected" + if ( + hasattr(models.MetaModel, "mixin_mappings") + and models.MetaModel.mixin_mappings.get(comodel_name) is not None + ): + return self.env[models.MetaModel.mixin_mappings[comodel_name]] + else: + return self.env.get(comodel_name) + + @api.model + def _extract_related_values(self, vals, key): + """ + Example: prepare nfe40_enderEmit partner legal_name and name + by reading nfe40_xNome and nfe40_xFant on nfe40_emit + """ + key_vals = {} + for k, v in self._fields.items(): + if ( + hasattr(v, "related") + and hasattr(v.related, "__len__") + and len(v.related) == 2 + and v.related[0] == key + and vals.get(k) is not None + ): + key_vals[v.related[1]] = vals[k] + return key_vals + + @api.model + def _prepare_import_dict( + self, vals, model=None, parent_dict=None, defaults_model=False + ): + """ + Set non computed field values based on XML values if required. + NOTE: this is debatable if we could use an api multi with values in + self instead of the vals dict. Then that would be like when new() + is used in account_invoice or sale_order before playing some onchanges + """ + if model is None: + model = self + + vals = {k: v for k, v in vals.items() if k in self._fields.keys()} + + related_many2ones = {} + fields = model._fields + for k, v in fields.items(): + # select schema choices for a friendly UI: + if k.startswith("%schoice" % (self._field_prefix,)): + for item in v.selection or []: + if vals.get(item[0]) not in [None, []]: + vals[k] = item[0] + break + + # reverse map related fields as much as possible + elif v.related is not None and vals.get(k) is not None: + if not hasattr(v, "__len__"): + related = v.related.split(".") + else: + related = v.related + if len(related) == 1: + vals[related[0]] = vals.get(k) + elif len(related) == 2 and k.startswith(self._field_prefix): + related_m2o = related[0] + # don't mess with _inherits write system + if not any(related_m2o == i[1] for i in model._inherits.items()): + key_vals = related_many2ones.get(related_m2o, {}) + key_vals[related[1]] = vals.get(k) + related_many2ones[related_m2o] = key_vals + + # now we deal with the related m2o with compound related + # (example: create Nfe lines product) + for related_m2o, sub_val in related_many2ones.items(): + comodel_name = fields[related_m2o].comodel_name + comodel = model.get_concrete_model(comodel_name) + related_many2ones = model._verify_related_many2ones(related_many2ones) + if hasattr(comodel, "match_or_create_m2o"): + vals[related_m2o] = comodel.match_or_create_m2o(sub_val, vals) + else: # search res.country with Brasil for instance + vals[related_m2o] = model.match_or_create_m2o(sub_val, vals, comodel) + + if defaults_model is not None: + defaults = defaults_model.with_context( + record_dict=vals, + parent_dict=parent_dict, + ).default_get( + [ + f + for f, v in defaults_model._fields.items() + if v.type not in ["binary", "integer", "float", "monetary"] + and v.name not in vals.keys() + ] + ) + vals.update(defaults) + # NOTE: also eventually load default values from the context? + return vals + + @api.model + def _verify_related_many2ones(self, related_many2ones): + return related_many2ones + + @api.model + def match_record(self, rec_dict, parent_dict, model=None): + """ + Inspired from match_* methods from + https://github.com/OCA/edi/blob/11.0/base_business_document_import + /models/business_document_import.py + """ + if model is None: + model = self + default_key = [model._rec_name or "name"] + search_keys = "_%s_search_keys" % (self._schema_name) + if hasattr(model, search_keys): + keys = getattr(model, search_keys) + default_key + else: + keys = [model._rec_name or "name"] + keys = self._get_aditional_keys(model, rec_dict, keys) + for key in keys: + if rec_dict.get(key): + # TODO enable to build criteria using parent_dict + # such as state_id when searching for a city + if hasattr(model, "_nfe_extra_domain"): # FIXME make generic + domain = model._nfe_extra_domain + [(key, "=", rec_dict.get(key))] + else: + domain = [(key, "=", rec_dict.get(key))] + match_ids = model.search(domain) + if match_ids: + if len(match_ids) > 1: + _logger.warning( + "!! WARNING more than 1 record found!! model: %s, domain: %s" + % (model, domain) + ) + return match_ids[0].id + return False + + @api.model + def _get_aditional_keys(self, model, rec_dict, keys): + return keys + + @api.model + def match_or_create_m2o(self, rec_dict, parent_dict, model=None): + """ + Often the parent_dict can be used to refine the search. + Passing the model makes it possible to override without inheriting + from this mixin. + """ + # TODO log things in chatter like in base_business_document_import + if model is None: + model = self + if hasattr(model, "_match_record"): + rec_id = model.match_record(rec_dict, parent_dict, model) + else: + rec_id = self.match_record(rec_dict, parent_dict, model) + if not rec_id: + vals = self._prepare_import_dict( + rec_dict, model=model, parent_dict=parent_dict, defaults_model=model + ) + if self._context.get("dry_run"): + rec = model.new(vals) + rec_id = rec.id + # at this point for NewId records, some fields + # may need to be set calling the inverse field functions: + for fname in vals: + field = model._fields.get(fname) + if isinstance(field.inverse, str): + getattr(rec, field.inverse)() + rec.write(vals) # ensure vals values aren't overriden + elif ( + field.inverse + and len(inspect.getfullargspec(field.inverse).args) < 2 + ): + field.inverse() + rec.write(vals) + else: + rec_id = ( + model.with_context( + parent_dict=parent_dict, + lang="en_US", + ) + .create(vals) + .id + ) + return rec_id diff --git a/spec_driven_model/models/spec_mixin.py b/spec_driven_model/models/spec_mixin.py new file mode 100644 index 000000000000..4537a6aeaa43 --- /dev/null +++ b/spec_driven_model/models/spec_mixin.py @@ -0,0 +1,32 @@ +# Copyright 2019-2020 Akretion - Raphael Valyi +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models + + +class SpecMixin(models.AbstractModel): + """putting this mixin here makes it possible for generated schemas mixins + to be installed without depending on the fiscal module. + """ + + _description = "root abstract model meant for xsd generated fiscal models" + _name = "spec.mixin" + _inherit = ["spec.mixin_export", "spec.mixin_import"] + _stacking_points = {} + # _spec_module = 'override.with.your.python.module' + # _binding_module = 'your.pyhthon.binding.module' + # _odoo_module = 'your.odoo_module' + # _field_prefix = 'your_field_prefix_' + # _schema_name = 'your_schema_name' + + def _valid_field_parameter(self, field, name): + if name in ( + "xsd_type", + "xsd_required", + "choice", + "xsd_choice_required", + "xsd_implicit", + ): + return True + else: + return super()._valid_field_parameter(field, name) diff --git a/spec_driven_model/models/spec_models.py b/spec_driven_model/models/spec_models.py new file mode 100644 index 000000000000..f2acc9c53608 --- /dev/null +++ b/spec_driven_model/models/spec_models.py @@ -0,0 +1,347 @@ +# Copyright 2019-TODAY Akretion - Raphael Valyi +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import collections +import logging +import sys +from inspect import getmembers, isclass + +from odoo import SUPERUSER_ID, _, api, models +from odoo.tools import mute_logger + +_logger = logging.getLogger(__name__) + + +class SelectionMuteLogger(mute_logger): + """ + The following fields.Selection warnings seem both very hard to + avoid and benign in the spec_driven_model framework context. + All in all, muting these 2 warnings seems like the best option. + """ + + def filter(self, record): + msg = record.getMessage() + if ( + "selection attribute will be ignored" in msg + or "overrides existing selection" in msg + ): + return 0 + return super().filter(record) + + +class SpecModel(models.Model): + """When you inherit this Model, then your model becomes concrete just like + models.Model and it can use _inherit to inherit from several xsd generated + spec mixins. + All your model relational fields will be automatically mutated according to + which concrete models the spec mixins where injected in. + Because of this field mutation logic in _build_model, SpecModel should be + inherited the Python way YourModel(spec_models.SpecModel) + and not through _inherit. + """ + + _inherit = "spec.mixin" + _auto = True # automatically create database backend + _register = False # not visible in ORM registry + _abstract = False + _transient = False + + # TODO generic onchange method that check spec field simple type formats + # xsd_required, according to the considered object context + # and return warning or reformat things + # ideally the list of onchange fields is set dynamically but if it is too + # hard, we can just dump the list of fields when SpecModel is loaded + + # TODO a save python constraint that ensuire xsd_required fields for the + # context are present + + @api.depends(lambda self: (self._rec_name,) if self._rec_name else ()) + def _compute_display_name(self): + "More user friendly when automatic _rec_name is bad" + res = super()._compute_display_name() + for rec in self: + if rec.display_name == "False" or not rec.display_name: + rec.display_name = _("Abrir...") + return res + + @classmethod + def _build_model(cls, pool, cr): + """ + xsd generated spec mixins do not need to depend on this opinionated + module. That's why the spec.mixin is dynamically injected as a parent + class as long as generated class inherit from some + spec.mixin. mixin. + """ + parents = [ + item[0] if isinstance(item, list) else item for item in list(cls._inherit) + ] + for parent in parents: + super_parents = list(pool[parent]._inherit) + for super_parent in super_parents: + if not super_parent.startswith("spec.mixin."): + continue + + cr.execute( + "SELECT name FROM ir_module_module " + "WHERE name=%s " + "AND state in ('to install', 'to upgrade', 'to remove')", + (pool[super_parent]._odoo_module,), + ) + if cr.fetchall(): + setattr( + pool, + "_%s_need_hook" % (pool[super_parent]._odoo_module,), + True, + ) + + cls._map_concrete(parent, cls._name) + if "spec.mixin" not in [ + c._name for c in pool[parent]._BaseModel__base_classes + ]: + pool[parent]._inherit = super_parents + ["spec.mixin"] + pool[parent]._BaseModel__base_classes = tuple( + [pool["spec.mixin"]] + + list(pool[parent]._BaseModel__base_classes) + ) + pool[parent].__bases__ = pool[parent]._BaseModel__base_classes + + return super()._build_model(pool, cr) + + @api.model + def _setup_base(self): + with SelectionMuteLogger("odoo.fields"): # mute spurious warnings + return super()._setup_base() + + @api.model + def _setup_fields(self): + """ + SpecModel models inherit their fields from XSD generated mixins. + These mixins can either be made concrete, either be injected into + existing concrete Odoo models. In that last case, the comodels of the + relational fields pointing to such mixins should be remapped to the + proper concrete models where these mixins are injected. + """ + cls = self.env.registry[self._name] + for klass in cls.__bases__: + if ( + not hasattr(klass, "_name") + or not hasattr(klass, "_fields") + or klass._name is None + or not klass._name.startswith(self.env[cls._name]._schema_name) + ): + continue + if klass._name != cls._name: + cls._map_concrete(klass._name, cls._name) + klass._table = cls._table + + stacked_parents = [getattr(x, "_name", None) for x in cls.mro()] + for name, field in cls._fields.items(): + if hasattr(field, "comodel_name") and field.comodel_name: + comodel_name = field.comodel_name + comodel = self.env[comodel_name] + concrete_class = cls._get_concrete(comodel._name) + + if ( + field.type == "many2one" + and concrete_class is not None + and comodel_name not in stacked_parents + ): + _logger.debug( + " MUTATING m2o %s (%s) -> %s", + name, + comodel_name, + concrete_class, + ) + field.original_comodel_name = comodel_name + field.comodel_name = concrete_class + + elif field.type == "one2many": + if concrete_class is not None: + _logger.debug( + " MUTATING o2m %s (%s) -> %s", + name, + comodel_name, + concrete_class, + ) + field.original_comodel_name = comodel_name + field.comodel_name = concrete_class + if not hasattr(field, "inverse_name"): + continue + inv_name = field.inverse_name + for n, f in comodel._fields.items(): + if n == inv_name and f.args and f.args.get("comodel_name"): + _logger.debug( + " MUTATING m2o %s.%s (%s) -> %s", + comodel._name.split(".")[-1], + n, + f.args["comodel_name"], + cls._name, + ) + f.args["original_comodel_name"] = f.args["comodel_name"] + f.args["comodel_name"] = self._name + + return super()._setup_fields() + + @classmethod + def _map_concrete(cls, key, target, quiet=False): + # TODO bookkeep according to a key to allow multiple injection contexts + if not hasattr(models.MetaModel, "mixin_mappings"): + models.MetaModel.mixin_mappings = {} + if not quiet: + _logger.debug("%s ---> %s" % (key, target)) + models.MetaModel.mixin_mappings[key] = target + + @classmethod + def _get_concrete(cls, key): + if not hasattr(models.MetaModel, "mixin_mappings"): + models.MetaModel.mixin_mappings = {} + return models.MetaModel.mixin_mappings.get(key) + + @classmethod + def spec_module_classes(cls, spec_module): + """ + Cache the list of spec_module classes to save calls to + slow reflection API. + """ + spec_module_attr = "_spec_cache_%s" % (spec_module.replace(".", "_"),) + if not hasattr(cls, spec_module_attr): + setattr( + cls, spec_module_attr, getmembers(sys.modules[spec_module], isclass) + ) + return getattr(cls, spec_module_attr) + + @classmethod + def _odoo_name_to_class(cls, odoo_name, spec_module): + for _name, base_class in cls.spec_module_classes(spec_module): + if base_class._name == odoo_name: + return base_class + return None + + def _register_hook(self): + res = super()._register_hook() + from .. import hooks # importing here avoids loop + + hooks.register_hook(self.env, self._odoo_module, self._spec_module) + return res + + +class StackedModel(SpecModel): + """ + XML structures are typically deeply nested as this helps xsd + validation. However, deeply nested objects in Odoo suck because that would + mean crazy joins accross many tables and also an endless cascade of form + popups. + + By inheriting from StackModel instead, your models.Model can + instead inherit all the mixins that would correspond to the nested xsd + nodes starting from the _stacked node. _stack_skip allows you to avoid + stacking specific nodes. + + In Brazil it allows us to have mostly the fiscal + document objects and the fiscal document line object with many details + stacked in a denormalized way inside these two tables only. + Because StackedModel has its _build_method overriden to do some magic + during module loading it should be inherited the Python way + with MyModel(spec_models.StackedModel). + """ + + _register = False # forces you to inherit StackeModel properly + + # define _stacked in your submodel to define the model of the XML tags + # where we should start to + # stack models of nested tags in the same object. + _stacked = False + _stack_path = "" + _stack_skip = () + # all m2o below these paths will be stacked even if not required: + _force_stack_paths = () + _stacking_points = {} + + @classmethod + def _build_model(cls, pool, cr): + # inject all stacked m2o as inherited classes + if cls._stacked: + _logger.info("building StackedModel %s %s" % (cls._name, cls)) + node = cls._odoo_name_to_class(cls._stacked, cls._spec_module) + env = api.Environment(cr, SUPERUSER_ID, {}) + for kind, klass, _path, _field_path, _child_concrete in cls._visit_stack( + env, node + ): + if kind == "stacked" and klass not in cls.__bases__: + cls.__bases__ = (klass,) + cls.__bases__ + return super()._build_model(pool, cr) + + @api.model + def _add_field(self, name, field): + for cls in type(self).mro(): + if issubclass(cls, StackedModel): + if name in type(self)._stacking_points.keys(): + return + return super()._add_field(name, field) + + @classmethod + def _visit_stack(cls, env, node, path=None): + """Pre-order traversal of the stacked models tree. + 1. This method is used to dynamically inherit all the spec models + stacked together from an XML hierarchy. + 2. It is also useful to generate an automatic view of the spec fields. + 3. Finally it is used when exporting as XML. + """ + # We are removing the description of the node + # to avoid translations error + # https://github.com/OCA/l10n-brazil/pull/1272#issuecomment-821806603 + node._description = None + if path is None: + path = cls._stacked.split(".")[-1] + SpecModel._map_concrete(node._name, cls._name, quiet=True) + yield "stacked", node, path, None, None + + fields = collections.OrderedDict() + # this is required when you don't start odoo with -i (update) + # otherwise the model spec will not have its fields loaded yet. + # TODO we may pass this env further instead of re-creating it. + # TODO move setup_base just before the _visit_stack next call + if node._name != cls._name or len(env[node._name]._fields.items() == 0): + env[node._name]._prepare_setup() + env[node._name]._setup_base() + + field_items = [(k, f) for k, f in env[node._name]._fields.items()] + for i in field_items: + fields[i[0]] = { + "type": i[1].type, + # TODO get with a function (lambda?) + "comodel_name": i[1].comodel_name, + "xsd_required": hasattr(i[1], "xsd_required") and i[1].xsd_required, + "xsd_choice_required": hasattr(i[1], "xsd_choice_required") + and i[1].xsd_choice_required, + } + for name, f in fields.items(): + if f["type"] not in ["many2one", "one2many"] or name in cls._stack_skip: + # TODO change for view or export + continue + child = cls._odoo_name_to_class(f["comodel_name"], cls._spec_module) + if child is None: # Not a spec field + continue + child_concrete = SpecModel._get_concrete(child._name) + field_path = name.replace(env[node._name]._field_prefix, "") + + if f["type"] == "one2many": + yield "one2many", node, path, field_path, child_concrete + continue + + force_stacked = any( + stack_path in path + "." + field_path + for stack_path in cls._force_stack_paths + ) + + # many2one + if (child_concrete is None or child_concrete == cls._name) and ( + f["xsd_required"] or f["xsd_choice_required"] or force_stacked + ): + # then we will STACK the child in the current class + child._stack_path = path + child_path = "%s.%s" % (path, field_path) + cls._stacking_points[name] = env[node._name]._fields.get(name) + yield from cls._visit_stack(env, child, child_path) + else: + yield "many2one", node, path, field_path, child_concrete diff --git a/spec_driven_model/models/spec_view.py b/spec_driven_model/models/spec_view.py new file mode 100644 index 000000000000..a3cad41e326d --- /dev/null +++ b/spec_driven_model/models/spec_view.py @@ -0,0 +1,348 @@ +# Copyright 2019-2020 Akretion - Raphael Valyi +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import logging + +from lxml import etree +from lxml.builder import E + +from odoo import api, models +from odoo.osv.orm import setup_modifiers + +_logger = logging.getLogger(__name__) + + +# TODO use MetaModel._get_concrete + + +class SpecViewMixin(models.AbstractModel): + _name = "spec.mixin_view" + + @api.model + def fields_view_get( + self, view_id=None, view_type="form", toolbar=False, submenu=False + ): + res = super(SpecViewMixin, self.with_context(no_subcall=True)).fields_view_get( + view_id, view_type, toolbar + ) + # _logger.info("+++++++++++++++", self, type(self), self._context) + if self._context.get("no_subcall"): + return res + # TODO collect class ancestors of StackedModel kind and + # extract the different XSD schemas injected. Then add a tab/page + # per schema unless context specify some specific schemas. + + # TODO override only if special dev or fiscal group or if spec_class in + # context + # TODO allow special XML placeholders to be replaced by proper fragment + if view_type == "form": + doc = etree.XML(res["arch"]) + fields = [] + if len(doc.xpath("//notebook")) > 0: + arch, fields = self._build_spec_fragment() + # arch.set("col", "4") TODO ex res.partner + node = doc.xpath("//notebook")[0] + page = E.page(string=self._spec_tab_name) + page.append(arch) + node.insert(1000, page) + elif len(doc.xpath("//sheet")) > 0: + arch, fields = self._build_spec_fragment() + node = doc.xpath("//sheet")[0] + arch.set("string", self._spec_tab_name) + arch.set("col", "2") # TODO ex fleet + if res["name"] == "default": + # we replace the default view by our own + # _logger.info("Defaulttttttt view:") + # _logger.info(etree.tostring(node, pretty_print=True).decode()) + for c in node.getchildren(): + node.remove(c) + arch = arch.getchildren()[0] + arch.set("col", "4") + node.insert(1000, arch) + # _logger.info("Specccccccccc View:") + # _logger.info(etree.tostring(node, pretty_print=True).decode()) + else: + node.insert(1000, arch) + elif len(doc.xpath("//form")) > 0: # ex invoice.line + arch, fields = self._build_spec_fragment() + node = doc.xpath("//form")[0] + arch.set("string", self._spec_tab_name) + arch.set("col", "2") + node.insert(1000, arch) + + # print("VIEW IS NOW:") + # print(etree.tostring(doc, pretty_print=True).decode()) + for field_name in fields: + if not self.fields_get().get(field_name): + continue + field = self.fields_get()[field_name] + if field["type"] in ["one2many", "many2one"]: + field["views"] = {} # no inline views + res["fields"][field_name] = field + field_nodes = doc.xpath("//field[@name='%s']" % (field_name,)) + for field_node in field_nodes: + setup_modifiers(field_node, field) + + res["arch"] = etree.tostring(doc) + return res + + @api.model + def _build_spec_fragment(self, container=None): + if container is None: + container = E.group() + # an export button that can help debug but messes views + # view_child = E.group() + # view_child.append(E.button( + # name='export_xml', + # type='object', + # string='Export', + # )) + # + # container.append(view_child) + fields = [] + if hasattr(type(self), "_stacked") and type(self)._stacked: + # we want the root of what is stacked to recreate the hierarchy + lib_model = self.env[type(self)._stacked] + self.build_arch(lib_model, container, fields, 0) + else: + if hasattr(self, "fiscal_document_id"): + lib_model = self.fiscal_document_id + elif hasattr(self, "fiscal_document_line_id"): + lib_model = self.fiscal_document_line_id + else: + lib_model = self + classes = [x._name for x in type(lib_model).mro() if hasattr(x, "_name")] + # _logger.info("#####", lib_model, classes) + for c in set(classes): + if c is None or not c.startswith("%s." % (self._schema_name,)): + continue + # the following filter to fields to show + # when several XSD class are injected in the same object + if self._context.get("spec_class") and c != self._context["spec_class"]: + continue + lib_model = self.env[c] + short_desc = lib_model._description.splitlines()[0] + sub_container = E.group(string=short_desc) + self.build_arch(lib_model, sub_container, fields, 1) + container.append(sub_container) + # _logger.info(etree.tostring(container, pretty_print=True).decode()) + return container, fields + + # TODO cache!! + # TODO pass schema arg (nfe_, nfse_) + # TODO required only if visible + @api.model + def build_arch(self, lib_node, view_node, fields, depth=0): + """Creates a view arch from an generateds lib model arch""" + # _logger.info("BUILD ARCH", lib_node) + choices = set() + wrapper_group = None + inside_notebook = False + stacked_classes = [x._name for x in type(self).mro() if hasattr(x, "_name")] + + # for spec in lib_node.member_data_items_: + for field_name, field in lib_node._fields.items(): + # _logger.info(" field", field_name) + # import pudb; pudb.set_trace() + + # skip automatic m2 fields, non xsd fields + # and display choice selector only where it is used + # (possibly later) + choice_prefix = "%schoice" % (self._field_prefix,) + if ( + "_id" in field_name + or self._field_prefix not in field_name + or choice_prefix in field_name + ): + continue + + # Odoo expects fields nested in 2 levels of group tags + # but past 2 levels, extra nesting ruins the layout + if depth == 0 and not wrapper_group: + wrapper_group = E.group() + + # should we create a choice block? + if hasattr(field, "choice"): + choice = field.choice + selector_name = "%s%s" % ( + choice_prefix, + choice, + ) + if choice not in choices: + choices.add(choice) + fields.append(selector_name) + selector_field = E.field(name=selector_name) + if wrapper_group is not None: + view_node.append(wrapper_group) + wrapper_group.append(selector_field) + else: + view_node.append(selector_field) + else: + selector_name = None + + if hasattr(field, "view_attrs"): + attrs = field.view_attrs + else: + if False: # TODO getattr(field, 'xsd_required', None): + pass + # TODO if inside optionaly visible group, required should + # be optional too + else: # assume dynamically required via attrs + pass + if selector_name is not None: + invisible = [("%s" % (selector_name,), "!=", field_name)] + attrs = {"invisible": invisible} + else: + attrs = False + + # complex m2o stacked child + if ( + hasattr(type(self), "_stacked") + and field.type == "many2one" + and field.comodel_name in stacked_classes + ): + self._build_form_complex_type( + field, fields, attrs, view_node, inside_notebook, depth + ) + + # simple type, o2m or m2o mapped to an Odoo object + else: + self._build_form_simple_type( + field, + fields, + attrs, + view_node, + field_name, + selector_name, + wrapper_group, + ) + + @api.model + def _build_form_simple_type( + self, + field, + fields, + attrs, + view_node, + field_name, + selector_name, + wrapper_group, + ): + fields.append(field_name) + + # TODO if inside optionaly visible group, required should optional too + required = False + if required and attrs: + dyn_required = "[('%s','=','%s')]" % (selector_name, field_name) + attrs["required"] = dyn_required + + # TODO the _stack_path assignation doesn't work + # if hasattr(field, '_stack_path'): # and + # field.args.get('_stack_path') is not None: + # path = getattr(field, '_stack_path') + + if hasattr(field, "original_comodel_name"): + spec_class = field.original_comodel_name + field_tag = E.field( + name=field_name, context="{'spec_class': '%s'})" % (spec_class,) + ) + else: + field_tag = E.field(name=field_name) + if attrs: + field_tag.set("attrs", "%s" % (attrs,)) + elif required: + field_tag.set("required", "True") + + if field.type in ("one2many", "many2many", "text", "html"): + if self.fields_get(field_name)[field_name].get("related"): + # avoid cluttering the view with large related fields + return + field_tag.set("colspan", "4") + view_node.append(E.newline()) + if wrapper_group is not None: + view_node.append(wrapper_group) + wrapper_group.append(field_tag) + else: + view_node.append(field_tag) + view_node.append(E.newline()) + else: + if wrapper_group is not None: + view_node.append(wrapper_group) + wrapper_group.append(field_tag) + else: + view_node.append(field_tag) + + @api.model + def _build_form_complex_type( + self, field, fields, attrs, view_node, inside_notebook, depth + ): + # TODO is is a suficient condition? + # study what happen in res.partner with dest#nfe_enderDest + # _logger.info('STACKED', field_name, field.comodel_name) + if hasattr(field, "original_comodel_name"): + lib_child = self.env[field.original_comodel_name] + else: + lib_child = self.env[field.comodel_name] + + child_string = field.string + # if isinstance(child_string, str): + # child_string = child_string # .decode('utf-8') + # if hasattr(lib_child, '_stack_path'): # TODO + # child_string = lib_child._stack_path + if depth == 0: + view_child = E.group(string=child_string) + if attrs: + view_child.set("attrs", "%s" % (attrs,)) + setup_modifiers(view_child) + view_node.append(view_child) + self.build_arch(lib_child, view_child, fields, depth + 1) + else: + page = E.page(string=child_string) + invisible = False + if attrs: + page.set("attrs", "%s" % (attrs,)) + setup_modifiers(page) + if not inside_notebook: + # first page + # this makes a difference in invoice line forms: + wrapper_notebook = E.notebook(colspan="2") + view_node.set("colspan", "2") + view_node.append(wrapper_notebook) + inside_notebook = True + if invisible: + # in case the notebook has only one page, + # the visibility should be carried by the + # notebook itself + wrapper_notebook.set("attrs", "{'invisible':%s}" % (invisible,)) + setup_modifiers(wrapper_notebook) + else: + # cancel notebook dynamic visbility + wrapper_notebook.set("attrs", "") + wrapper_notebook.set("modifiers", "") + view_child = E.group() + page.append(view_child) + wrapper_notebook.append(page) # TODO attrs / choice + # TODO inherit required + self.build_arch(lib_child, view_child, fields, 0) + + @api.model + def _get_default_tree_view(self): + """Generates a single-field tree view, based on _rec_name. + :returns: a tree view as an lxml document + :rtype: etree._Element + """ + desc = self._description + tree = E.tree(string=desc) + c = 0 + required_fields_num = len([f[1] for f in self._fields.items() if f[1].required]) + for fname, field in self._fields.items(): + if field.automatic or fname == "currency_id": + continue + if len(self._fields) > 7 and required_fields_num > 2 and not field.required: + continue + else: + tree.append(E.field(name=fname)) + if c > 12: + break + c += 1 + return tree diff --git a/spec_driven_model/readme/CONFIGURE.rst b/spec_driven_model/readme/CONFIGURE.rst new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/spec_driven_model/readme/CONTRIBUTORS.rst b/spec_driven_model/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000000..97db3f5c486c --- /dev/null +++ b/spec_driven_model/readme/CONTRIBUTORS.rst @@ -0,0 +1,7 @@ +* `AKRETION `_: + + * Raphaël Valyi + +* `KMEE `_: + + * Gabriel Cardoso de Faria diff --git a/spec_driven_model/readme/DESCRIPTION.rst b/spec_driven_model/readme/DESCRIPTION.rst new file mode 100644 index 000000000000..9810adf5c3e4 --- /dev/null +++ b/spec_driven_model/readme/DESCRIPTION.rst @@ -0,0 +1,58 @@ +Intro +~~~~~ + +This module is a databinding framework for Odoo and XML data: it allows to go from XML to Odoo objects back and forth. This module started with the `GenerateDS `_ pure Python databinding framework and is now being migrated to xsdata. So a good starting point is to read `the xsdata documentation here `_ + +But what if instead of only generating Python structures from XML files you could actually generate full blown Odoo objects or serialize Odoo objects back to XML? This is what this module is for! + +First you should generate xsdata Python binding libraries you would generate for your specific XSD grammar, the Brazilian Electronic Invoicing for instance, or UBL. + +Second you should generate Odoo abstract mixins for all these pure Python bindings. This can be achieved using `xsdata-odoo `_. An example is OCA/l10n-brazil/l10n_br_nfe_spec for the Brazilian Electronic Invoicing. + +SpecModel +~~~~~~~~~ + +Now that you have generated these Odoo abstract bindings you should tell Odoo how to use them. For instance you may want that your electronic invoice abstract model matches the Odoo `res.partner` object. This is fairly easy, you mostly need to define an override like:: + + + from odoo.addons.spec_driven_model.models import spec_models + + + class ResPartner(spec_models.SpecModel): + _inherit = [ + 'res.partner', + 'partner.binding.mixin', + ] + +Notice you should inherit from `spec_models.SpecModel` and not the usual `models.Model`. + +**Field mapping**: You can then define two ways mapping between fields by overriding fields from Odoo or from the binding and using `_compute=` , `_inverse=` or simply `related=`. + +**Relational fields**: simple fields are easily mapped this way. However what about relational fields? In your XSD schema, your electronic invoice is related to the `partner.binding.mixin` not to an Odoo `res.partner`. Don't worry, when `SpecModel` classes are instanciated for all relational fields, we look if their comodel have been injected into some existing Odoo model and if so we remap them to the proper Odoo model. + +**Field prefixes**: to avoid field collision between the Odoo fields and the XSD fields, the XSD fields are prefixed with the name of the schema and a few digits representing the schema version (typically 2 digits). So if your schema get a minor version upgrade, the same fields and classes are used. For a major upgrade however new fields and classes may be used so data of several major versions could co-exist inside your Odoo database. + + +StackedModel +~~~~~~~~~~~~ + +Sadly real life XML is a bit more complex than that. Often XML structures are deeply nested just because it makes it easier for XSD schemas to validate them! for instance an electronic invoice line can be a nested structure with lots of tax details and product details. In a relational model like Odoo however you often want flatter data structures. This is where `StackedModel` comes to the rescue! It inherits from `SpecModel` and when you inherit from `StackedModel` you can inherit from all the generated mixins corresponding to the nested XML tags below some tag (here `invoice.line.binding.mixin`). All the fields corresponding to these XML tag attributes will be collected in your model and the XML parsing and serialization will happen as expected:: + + + from odoo.addons.spec_driven_model.models import spec_models + + + class InvoiceLine(spec_models.StackedModel): + _inherit = [ + 'account.move.line', + 'invoice.line.binding.mixin', + ] + _stacked = 'invoice.line.binding.mixin' + +All many2one fields that are required in the XSD (xsd_required=True) will get their model stacked automatically and recursively. You can force non required many2one fields to be stacked using the `_force_stack_paths` attribute. On the contrary, you can avoid some required many2one fields to be stacked using the `stack_skip` attribute. + + +Hooks +~~~~~ + +Because XSD schemas can define lot's of different models, spec_driven_model comes with handy hooks that will automatically make all XSD mixins turn into concrete Odoo model (eg with a table) if you didn't inject them into existing Odoo models. diff --git a/spec_driven_model/readme/HISTORY.rst b/spec_driven_model/readme/HISTORY.rst new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/spec_driven_model/readme/INSTALL.rst b/spec_driven_model/readme/INSTALL.rst new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/spec_driven_model/readme/ROADMAP.rst b/spec_driven_model/readme/ROADMAP.rst new file mode 100644 index 000000000000..da9564739460 --- /dev/null +++ b/spec_driven_model/readme/ROADMAP.rst @@ -0,0 +1 @@ +Migrate from generateDS to xsdata; see the xsdata Pull Requests in the repo. diff --git a/spec_driven_model/readme/USAGE.rst b/spec_driven_model/readme/USAGE.rst new file mode 100644 index 000000000000..da33eb2b9e26 --- /dev/null +++ b/spec_driven_model/readme/USAGE.rst @@ -0,0 +1,4 @@ +See my detailed OCA Days explanations here: +https://www.youtube.com/watch?v=6gFOe7Wh8uA + +You are also encouraged to look at the tests directory which features a full blown example from the famous PurchaseOrder.xsd from Microsoft tutorials. diff --git a/spec_driven_model/static/description/icon.png b/spec_driven_model/static/description/icon.png new file mode 100644 index 000000000000..3a0328b516c4 Binary files /dev/null and b/spec_driven_model/static/description/icon.png differ diff --git a/spec_driven_model/static/description/index.html b/spec_driven_model/static/description/index.html new file mode 100644 index 000000000000..67ede7c79b3f --- /dev/null +++ b/spec_driven_model/static/description/index.html @@ -0,0 +1,481 @@ + + + + + + +Spec Driven Model + + + +
+

Spec Driven Model

+ + +

Beta License: LGPL-3 OCA/l10n-brazil Translate me on Weblate Try me on Runboat

+
+

Intro

+

This module is a databinding framework for Odoo and XML data: it allows to go from XML to Odoo objects back and forth. This module started with the GenerateDS pure Python databinding framework and is now being migrated to xsdata. So a good starting point is to read the xsdata documentation here

+

But what if instead of only generating Python structures from XML files you could actually generate full blown Odoo objects or serialize Odoo objects back to XML? This is what this module is for!

+

First you should generate xsdata Python binding libraries you would generate for your specific XSD grammar, the Brazilian Electronic Invoicing for instance, or UBL.

+

Second you should generate Odoo abstract mixins for all these pure Python bindings. This can be achieved using xsdata-odoo. An example is OCA/l10n-brazil/l10n_br_nfe_spec for the Brazilian Electronic Invoicing.

+
+
+

SpecModel

+

Now that you have generated these Odoo abstract bindings you should tell Odoo how to use them. For instance you may want that your electronic invoice abstract model matches the Odoo res.partner object. This is fairly easy, you mostly need to define an override like:

+
+from odoo.addons.spec_driven_model.models import spec_models
+
+
+class ResPartner(spec_models.SpecModel):
+    _inherit = [
+        'res.partner',
+        'partner.binding.mixin',
+    ]
+
+

Notice you should inherit from spec_models.SpecModel and not the usual models.Model.

+

Field mapping: You can then define two ways mapping between fields by overriding fields from Odoo or from the binding and using _compute= , _inverse= or simply related=.

+

Relational fields: simple fields are easily mapped this way. However what about relational fields? In your XSD schema, your electronic invoice is related to the partner.binding.mixin not to an Odoo res.partner. Don’t worry, when SpecModel classes are instanciated for all relational fields, we look if their comodel have been injected into some existing Odoo model and if so we remap them to the proper Odoo model.

+

Field prefixes: to avoid field collision between the Odoo fields and the XSD fields, the XSD fields are prefixed with the name of the schema and a few digits representing the schema version (typically 2 digits). So if your schema get a minor version upgrade, the same fields and classes are used. For a major upgrade however new fields and classes may be used so data of several major versions could co-exist inside your Odoo database.

+
+
+

StackedModel

+

Sadly real life XML is a bit more complex than that. Often XML structures are deeply nested just because it makes it easier for XSD schemas to validate them! for instance an electronic invoice line can be a nested structure with lots of tax details and product details. In a relational model like Odoo however you often want flatter data structures. This is where StackedModel comes to the rescue! It inherits from SpecModel and when you inherit from StackedModel you can inherit from all the generated mixins corresponding to the nested XML tags below some tag (here invoice.line.binding.mixin). All the fields corresponding to these XML tag attributes will be collected in your model and the XML parsing and serialization will happen as expected:

+
+from odoo.addons.spec_driven_model.models import spec_models
+
+
+class InvoiceLine(spec_models.StackedModel):
+    _inherit = [
+        'account.move.line',
+        'invoice.line.binding.mixin',
+    ]
+    _stacked = 'invoice.line.binding.mixin'
+
+

All many2one fields that are required in the XSD (xsd_required=True) will get their model stacked automatically and recursively. You can force non required many2one fields to be stacked using the _force_stack_paths attribute. On the contrary, you can avoid some required many2one fields to be stacked using the stack_skip attribute.

+
+
+

Hooks

+

Because XSD schemas can define lot’s of different models, spec_driven_model comes with handy hooks that will automatically make all XSD mixins turn into concrete Odoo model (eg with a table) if you didn’t inject them into existing Odoo models.

+

Table of contents

+ +
+

Usage

+

See my detailed OCA Days explanations here: +https://www.youtube.com/watch?v=6gFOe7Wh8uA

+

You are also encouraged to look at the tests directory which features a full blown example from the famous PurchaseOrder.xsd from Microsoft tutorials.

+
+
+

Known issues / Roadmap

+

Migrate from generateDS to xsdata; see the xsdata Pull Requests in the repo.

+
+
+

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 to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+ +
+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

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.

+

Current maintainer:

+

rvalyi

+

This module is part of the OCA/l10n-brazil project on GitHub.

+

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

+
+
+ + diff --git a/spec_driven_model/tests/PurchaseOrderSchema.xsd b/spec_driven_model/tests/PurchaseOrderSchema.xsd new file mode 100644 index 000000000000..ee1dd3028e22 --- /dev/null +++ b/spec_driven_model/tests/PurchaseOrderSchema.xsd @@ -0,0 +1,65 @@ + + + + + + + + + Purchase order schema for Example.Microsoft.com. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec_driven_model/tests/__init__.py b/spec_driven_model/tests/__init__.py new file mode 100644 index 000000000000..c1e5e2a4f7a7 --- /dev/null +++ b/spec_driven_model/tests/__init__.py @@ -0,0 +1 @@ +from . import test_spec_model diff --git a/spec_driven_model/tests/fake_mixin.py b/spec_driven_model/tests/fake_mixin.py new file mode 100644 index 000000000000..7f3887edf35d --- /dev/null +++ b/spec_driven_model/tests/fake_mixin.py @@ -0,0 +1,33 @@ +# Copyright 2021 Akretion - Raphael Valyi +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import fields, models + + +class PoXsdMixin(models.AbstractModel): + _description = "Abstract Model for PO XSD" + _name = "spec.mixin.poxsd" + _field_prefix = "poxsd10_" + _schema_name = "poxsd" + _schema_version = "1.0" + _odoo_module = "poxsd" + _spec_module = "odoo.addons.spec_driven_model.tests.spec_poxsd" + _binding_module = "odoo.addons.spec_driven_model.tests.purchase_order_lib" + + # TODO rename + brl_currency_id = fields.Many2one( + comodel_name="res.currency", + string="Moeda", + compute="_compute_brl_currency_id", + default=lambda self: self.env.ref("base.EUR").id, + ) + + def _compute_brl_currency_id(self): + for item in self: + item.brl_currency_id = self.env.ref("base.EUR").id + + def _valid_field_parameter(self, field, name): + if name in ("xsd_type", "xsd_required", "choice", "xsd_implicit"): + return True + else: + return super()._valid_field_parameter(field, name) diff --git a/spec_driven_model/tests/fake_odoo_purchase.py b/spec_driven_model/tests/fake_odoo_purchase.py new file mode 100644 index 000000000000..891acdfc9772 --- /dev/null +++ b/spec_driven_model/tests/fake_odoo_purchase.py @@ -0,0 +1,81 @@ +# Copyright 2021 Akretion - Raphael Valyi +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import fields, models + +# This is a very simplified copy of the Odoo purchase order model +# in the real life you would inject your xsd spec models into the +# models of the real Odoo purchase module. + + +class PurchaseOrder(models.Model): + _name = "fake.purchase.order" + _description = "Purchase Order" + + READONLY_STATES = { + "purchase": [("readonly", True)], + "done": [("readonly", True)], + "cancel": [("readonly", True)], + } + + name = fields.Char("Order Reference", required=True, default="New") + date_order = fields.Datetime( + "Order Date", required=True, states=READONLY_STATES, default=fields.Datetime.now + ) + date_approve = fields.Date("Approval Date", readonly=1) + partner_id = fields.Many2one( + "res.partner", + string="Vendor", + required=True, + states=READONLY_STATES, + change_default=True, + ) + dest_address_id = fields.Many2one( + "res.partner", string="Drop Ship Address", states=READONLY_STATES + ) + currency_id = fields.Many2one( + "res.currency", + "Currency", + required=True, + default=lambda self: self.env.ref("base.EUR").id, + ) + state = fields.Selection( + [ + ("draft", "RFQ"), + ("sent", "RFQ Sent"), + ("to approve", "To Approve"), + ("purchase", "Purchase Order"), + ("done", "Locked"), + ("cancel", "Cancelled"), + ], + string="Status", + readonly=True, + index=True, + copy=False, + default="draft", + tracking=True, + ) + + order_line = fields.One2many( + "fake.purchase.order.line", "order_id", string="Order Lines" + ) + + +class PurchaseOrderLine(models.Model): + _name = "fake.purchase.order.line" + _description = "Purchase Order Line" + + name = fields.Char(string="Description", required=True) + sequence = fields.Integer(string="Sequence", default=10) + product_qty = fields.Integer(string="Quantity", required=True) + price_unit = fields.Monetary(string="Unit Price", required=True) + currency_id = fields.Many2one( + related="order_id.currency_id", store=True, string="Currency", readonly=True + ) + order_id = fields.Many2one( + "fake.purchase.order", + string="Order Reference", + index=True, + required=True, + ondelete="cascade", + ) diff --git a/spec_driven_model/tests/purchase_order_lib.py b/spec_driven_model/tests/purchase_order_lib.py new file mode 100644 index 000000000000..8d288bdec230 --- /dev/null +++ b/spec_driven_model/tests/purchase_order_lib.py @@ -0,0 +1,207 @@ +# file generated using: +# xsdata generate spec_driven_model/tests/PurchaseOrderSchema.xsd + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import List, Optional + +from xsdata.models.datatype import XmlDate + +__NAMESPACE__ = "http://tempuri.org/PurchaseOrderSchema.xsd" + + +@dataclass +class Items: + item: List["Items.Item"] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://tempuri.org/PurchaseOrderSchema.xsd", + }, + ) + + @dataclass + class Item: + product_name: Optional[str] = field( + default=None, + metadata={ + "name": "productName", + "type": "Element", + "namespace": "http://tempuri.org/PurchaseOrderSchema.xsd", + "required": True, + }, + ) + quantity: Optional[int] = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://tempuri.org/PurchaseOrderSchema.xsd", + "required": True, + "min_inclusive": 1, + "max_exclusive": 100, + }, + ) + usprice: Optional[Decimal] = field( + default=None, + metadata={ + "name": "USPrice", + "type": "Element", + "namespace": "http://tempuri.org/PurchaseOrderSchema.xsd", + "required": True, + }, + ) + comment: Optional[str] = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://tempuri.org/PurchaseOrderSchema.xsd", + "required": True, + }, + ) + ship_date: Optional[XmlDate] = field( + default=None, + metadata={ + "name": "shipDate", + "type": "Element", + "namespace": "http://tempuri.org/PurchaseOrderSchema.xsd", + }, + ) + part_num: Optional[str] = field( + default=None, + metadata={ + "name": "partNum", + "type": "Attribute", + "pattern": r"\d{3}\w{3}", + }, + ) + + +@dataclass +class Usaddress: + """ + Purchase order schema for Example.Microsoft.com. + """ + + class Meta: + name = "USAddress" + + name: Optional[str] = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://tempuri.org/PurchaseOrderSchema.xsd", + "required": True, + }, + ) + street: Optional[str] = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://tempuri.org/PurchaseOrderSchema.xsd", + "required": True, + }, + ) + city: Optional[str] = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://tempuri.org/PurchaseOrderSchema.xsd", + "required": True, + }, + ) + state: Optional[str] = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://tempuri.org/PurchaseOrderSchema.xsd", + "required": True, + }, + ) + zip: Optional[Decimal] = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://tempuri.org/PurchaseOrderSchema.xsd", + "required": True, + }, + ) + country: str = field( + init=False, + default="US", + metadata={ + "type": "Attribute", + }, + ) + + +@dataclass +class Comment: + class Meta: + name = "comment" + namespace = "http://tempuri.org/PurchaseOrderSchema.xsd" + + value: str = field( + default="", + metadata={ + "required": True, + }, + ) + + +@dataclass +class PurchaseOrderType: + ship_to: Optional[Usaddress] = field( + default=None, + metadata={ + "name": "shipTo", + "type": "Element", + "namespace": "http://tempuri.org/PurchaseOrderSchema.xsd", + "required": True, + }, + ) + bill_to: Optional[Usaddress] = field( + default=None, + metadata={ + "name": "billTo", + "type": "Element", + "namespace": "http://tempuri.org/PurchaseOrderSchema.xsd", + "required": True, + }, + ) + comment: Optional[str] = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://tempuri.org/PurchaseOrderSchema.xsd", + }, + ) + items: Optional[Items] = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://tempuri.org/PurchaseOrderSchema.xsd", + "required": True, + }, + ) + order_date: Optional[XmlDate] = field( + default=None, + metadata={ + "name": "orderDate", + "type": "Attribute", + }, + ) + confirm_date: Optional[XmlDate] = field( + default=None, + metadata={ + "name": "confirmDate", + "type": "Attribute", + "required": True, + }, + ) + + +@dataclass +class PurchaseOrder(PurchaseOrderType): + class Meta: + name = "purchaseOrder" + namespace = "http://tempuri.org/PurchaseOrderSchema.xsd" diff --git a/spec_driven_model/tests/spec_poxsd.py b/spec_driven_model/tests/spec_poxsd.py new file mode 100644 index 000000000000..44fc633b756c --- /dev/null +++ b/spec_driven_model/tests/spec_poxsd.py @@ -0,0 +1,99 @@ +# Copyright 2022 Akretion - Raphaël Valyi +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +# Generated by https://github.com/akretion/xsdata-odoo +# +import textwrap + +from odoo import fields, models + +__NAMESPACE__ = "http://tempuri.org/PurchaseOrderSchema.xsd" + + +class Items(models.AbstractModel): + _description = "Items" + _name = "poxsd.10.items" + _inherit = "spec.mixin.poxsd" + _binding_type = "Items" + + poxsd10_item = fields.One2many( + "poxsd.10.item", "poxsd10_item_Items_id", string="item" + ) + + +class Item(models.AbstractModel): + _description = "item" + _name = "poxsd.10.item" + _inherit = "spec.mixin.poxsd" + _binding_type = "Items.Item" + + poxsd10_item_Items_id = fields.Many2one( + comodel_name="poxsd.10.items", xsd_implicit=True, ondelete="cascade" + ) + poxsd10_productName = fields.Float(xsd_required=True, string="productName") + poxsd10_quantity = fields.Integer(xsd_required=True, string="quantity") + poxsd10_USPrice = fields.Float( + xsd_type="xsd:decimal", xsd_required=True, string="USPrice" + ) + poxsd10_comment = fields.Float(xsd_required=True, string="comment") + poxsd10_shipDate = fields.Float(string="shipDate") + poxsd10_partNum = fields.Float(xsd_type="tns:SKU", string="partNum") + + +class Usaddress(models.AbstractModel): + "Purchase order schema for Example.Microsoft.com." + _description = textwrap.dedent(" %s" % (__doc__,)) + _name = "poxsd.10.usaddress" + _inherit = "spec.mixin.poxsd" + _binding_type = "Usaddress" + + poxsd10_name = fields.Float(xsd_required=True, string="name") + poxsd10_street = fields.Float(xsd_required=True, string="street") + poxsd10_city = fields.Float(xsd_required=True, string="city") + poxsd10_state = fields.Float(xsd_required=True, string="state") + poxsd10_zip = fields.Float(xsd_type="xsd:decimal", xsd_required=True, string="zip") + poxsd10_country = fields.Float(xsd_type="xsd:NMTOKEN", string="country") + + +class Comment(models.AbstractModel): + _description = "comment" + _name = "poxsd.10.comment" + _inherit = "spec.mixin.poxsd" + _binding_type = "Comment" + + poxsd10_value = fields.Float(xsd_required=True, string="value") + + +class PurchaseOrderType(models.AbstractModel): + _description = "PurchaseOrderType" + _name = "poxsd.10.purchaseordertype" + _inherit = "spec.mixin.poxsd" + _binding_type = "PurchaseOrderType" + + poxsd10_shipTo = fields.Many2one( + xsd_type="tns:USAddress", + xsd_required=True, + string="shipTo", + comodel_name="poxsd.10.usaddress", + ) + poxsd10_billTo = fields.Many2one( + xsd_type="tns:USAddress", + xsd_required=True, + string="billTo", + comodel_name="poxsd.10.usaddress", + ) + poxsd10_comment = fields.Float(string="comment") + poxsd10_items = fields.Many2one( + xsd_type="tns:Items", + xsd_required=True, + string="items", + comodel_name="poxsd.10.items", + ) + poxsd10_orderDate = fields.Float(string="orderDate") + poxsd10_confirmDate = fields.Float(xsd_required=True, string="confirmDate") + + +class PurchaseOrder(models.AbstractModel): + _description = "purchaseOrder" + _name = "poxsd.10.purchaseorder" + _inherit = "spec.mixin.poxsd" + _binding_type = "PurchaseOrder" diff --git a/spec_driven_model/tests/spec_purchase.py b/spec_driven_model/tests/spec_purchase.py new file mode 100644 index 000000000000..9f01b44f760e --- /dev/null +++ b/spec_driven_model/tests/spec_purchase.py @@ -0,0 +1,60 @@ +# Copyright 2021 Akretion - Raphael Valyi +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import fields + +from odoo.addons.spec_driven_model.models import spec_models + + +class ResPartner(spec_models.SpecModel): + _name = "res.partner" + _inherit = ["res.partner", "poxsd.10.usaddress"] + + poxsd10_country = fields.Char(related="country_id.name") + poxsd10_name = fields.Char(related="name") + poxsd10_street = fields.Char(related="street") + poxsd10_city = fields.Char(related="city") + poxsd10_state = fields.Char(related="state_id.name") + # FIXME !! + # poxsd10_zip = fields.Monetary( + # currency_field="brl_currency_id", + # string="zip", xsd_required=True, + # xsd_type="decimal") + + +class PurchaseOrderLine(spec_models.SpecModel): + _name = "fake.purchase.order.line" + _inherit = ["fake.purchase.order.line", "poxsd.10.item"] + + poxsd10_productName = fields.Char(related="name") + poxsd10_quantity = fields.Integer(related="product_qty") + poxsd10_USPrice = fields.Monetary(related="price_unit") + + +class PurchaseOrder(spec_models.StackedModel): + """ + We use StackedModel to ensure the m2o poxsd10_items field + from poxsd.10.purchaseorder get its content (the Items class + with the poxsd10_item o2m field included inside PurchaseOrder). + This poxsd10_item is then related to the purchase.order order_id o2m field. + """ + + _name = "fake.purchase.order" + _inherit = ["fake.purchase.order", "poxsd.10.purchaseordertype"] + _spec_module = "odoo.addons.spec_driven_model.tests.spec_poxsd" + _stacked = "poxsd.10.purchaseordertype" + _stacking_points = {} + _poxsd10_spec_module_classes = None + + poxsd10_orderDate = fields.Date(compute="_compute_date") + poxsd10_confirmDate = fields.Date(related="date_approve") + poxsd10_shipTo = fields.Many2one(related="dest_address_id", readonly=False) + poxsd10_billTo = fields.Many2one(related="partner_id", readonly=False) + poxsd10_item = fields.One2many(related="order_line", relation_field="order_id") + + def _compute_date(self): + """ + Example of data casting to accomodate with the xsd model + """ + for po in self: + po.poxsd10_orderDate = po.date_order.date() diff --git a/spec_driven_model/tests/test_spec_model.py b/spec_driven_model/tests/test_spec_model.py new file mode 100644 index 000000000000..6e9c61d5f8f2 --- /dev/null +++ b/spec_driven_model/tests/test_spec_model.py @@ -0,0 +1,179 @@ +# Copyright 2021 Akretion - Raphael Valyi +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo_test_helper import FakeModelLoader + +from odoo.models import NewId +from odoo.tests import TransactionCase + +from ..hooks import get_remaining_spec_models + + +class TestSpecModel(TransactionCase, FakeModelLoader): + """ + A simple usage example using the reference PurchaseOrderSchema.xsd + https://docs.microsoft.com/en-us/visualstudio/xml-tools/sample-xsd-file-purchase-order-schema?view=vs-2019 + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + + # import a simpilified equivalend of purchase module + from .fake_mixin import PoXsdMixin + from .spec_poxsd import ( + Items, + Item, + Usaddress, + Comment, + PurchaseOrderType, + PurchaseOrder as PurchaseOrderXsd, + ) + from .fake_odoo_purchase import ( + PurchaseOrder as FakePurchaseOrder, + PurchaseOrderLine as FakePurchaseOrderLine, + ) + from .spec_purchase import ( + ResPartner, + PurchaseOrder as SpecPurchaseOrder, + PurchaseOrderLine as SpecPurchaseOrderLine, + ) + + cls.loader.update_registry( + ( + PoXsdMixin, + Items, + Item, + Usaddress, + Comment, + PurchaseOrderType, + PurchaseOrderXsd, + ResPartner, + FakePurchaseOrder, + FakePurchaseOrderLine, + SpecPurchaseOrder, + SpecPurchaseOrderLine, + ) + ) + + # import generated spec mixins + from .fake_mixin import PoXsdMixin + from .spec_poxsd import Item, Items, PurchaseOrderType, Usaddress + + cls.loader.update_registry( + (PoXsdMixin, Item, Items, Usaddress, PurchaseOrderType) + ) + + # inject the mixins into existing Odoo models + from .spec_purchase import ( + PurchaseOrder as PurchaseOrder2, + PurchaseOrderLine, + ResPartner, + ) + + cls.loader.update_registry((ResPartner, PurchaseOrderLine, PurchaseOrder2)) + # the binding lib should be loaded in sys.modules: + from . import purchase_order_lib # NOQA + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super(TestSpecModel, cls).tearDownClass() + + def test_loading_hook(self): + remaining_spec_models = get_remaining_spec_models( + self.env.cr, + self.env.registry, + "spec_driven_model", + "odoo.addons.spec_driven_model.tests.spec_poxsd", + ) + self.assertEqual( + remaining_spec_models, {"poxsd.10.purchaseorder", "poxsd.10.comment"} + ) + + def test_spec_models(self): + self.assertTrue( + set(self.env["res.partner"]._fields.keys()).issuperset( + set(self.env["poxsd.10.usaddress"]._fields.keys()) + ) + ) + + self.assertTrue( + set(self.env["fake.purchase.order.line"]._fields.keys()).issuperset( + set(self.env["poxsd.10.item"]._fields.keys()) + ) + ) + + def test_stacked_model(self): + po_fields_or_stacking = set(self.env["fake.purchase.order"]._fields.keys()) + po_fields_or_stacking.update( + set(self.env["fake.purchase.order"]._stacking_points.keys()) + ) + self.assertTrue( + po_fields_or_stacking.issuperset( + set(self.env["poxsd.10.purchaseordertype"]._fields.keys()) + ) + ) + self.assertEqual( + list(self.env["fake.purchase.order"]._stacking_points.keys()), + ["poxsd10_items"], + ) + + # let's ensure fields are remapped to their proper concrete types: + self.assertEqual( + self.env["fake.purchase.order"]._fields["poxsd10_shipTo"].comodel_name, + "res.partner", + ) + self.assertEqual( + self.env["fake.purchase.order"]._fields["poxsd10_billTo"].comodel_name, + "res.partner", + ) + + self.assertEqual( + self.env["fake.purchase.order"]._fields["poxsd10_item"].comodel_name, + "fake.purchase.order.line", + ) + + def test_create_export_import(self): + + # 1st we create an Odoo PO: + po = self.env["fake.purchase.order"].create( + { + "name": "PO XSD", + "partner_id": self.env.ref("base.res_partner_1").id, + "dest_address_id": self.env.ref("base.res_partner_1").id, + } + ) + self.env["fake.purchase.order.line"].create( + { + "name": "Some product desc", + "product_qty": 42, + "price_unit": 13, + "order_id": po.id, + } + ) + + # 2nd we serialize it into a binding object: + # (that could be further XML serialized) + po_binding = po._build_generateds() + self.assertEqual(po_binding.bill_to.name, "Wood Corner") + self.assertEqual(po_binding.items.item[0].product_name, "Some product desc") + self.assertEqual(po_binding.items.item[0].quantity, 42) + self.assertEqual(po_binding.items.item[0].usprice, "13") # FIXME + + # 3rd we import an Odoo PO from this binding object + # first we will do a dry run import: + imported_po_dry_run = self.env["fake.purchase.order"].build_from_binding( + po_binding, dry_run=True + ) + assert isinstance(imported_po_dry_run.id, NewId) + + # now a real import: + imported_po = self.env["fake.purchase.order"].build_from_binding(po_binding) + self.assertEqual(imported_po.partner_id.name, "Wood Corner") + self.assertEqual( + imported_po.partner_id.id, self.env.ref("base.res_partner_1").id + ) + self.assertEqual(imported_po.order_line[0].name, "Some product desc") diff --git a/test-requirements.txt b/test-requirements.txt index a3122cbffcb7..6bde5072dd5a 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,3 +1,5 @@ +vcrpy +odoo-test-helper # Needed by spec_driven_model +pyopenssl==22.1.0 nfelib<=2.0.7 -vcrpy # Needed by payment_pagseguro xsdata