diff --git a/l10n_br_account_nfe/tests/test_nfce_contingency.py b/l10n_br_account_nfe/tests/test_nfce_contingency.py index 954eb7db3683..17cdb46b6449 100644 --- a/l10n_br_account_nfe/tests/test_nfce_contingency.py +++ b/l10n_br_account_nfe/tests/test_nfce_contingency.py @@ -8,8 +8,6 @@ class TestAccountNFCeContingency(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() - # this hook is required to test l10n_br_account_nfe alone: - cls.env["spec.mixin.nfe"]._register_hook() cls.document_id = cls.env.ref("l10n_br_nfe.demo_nfce_same_state") cls.prepare_account_move_nfce() diff --git a/l10n_br_nfe/hooks.py b/l10n_br_nfe/hooks.py index 549504601e50..b89c621ef816 100644 --- a/l10n_br_nfe/hooks.py +++ b/l10n_br_nfe/hooks.py @@ -14,7 +14,6 @@ def post_init_hook(cr, registry): env = api.Environment(cr, SUPERUSER_ID, {}) - env["nfe.40.infnfe"]._register_hook() cr.execute("select demo from ir_module_module where name='l10n_br_nfe';") is_demo = cr.fetchone()[0] if is_demo: @@ -37,7 +36,7 @@ def post_init_hook(cr, registry): nfe = ( env["nfe.40.infnfe"] .with_context(tracking_disable=True, edoc_type="in") - .build_from_binding(binding.NFe.infNFe) + .build_from_binding("nfe", "40", binding.NFe.infNFe) ) _logger.info(nfe.nfe40_emit.nfe40_CNPJ) except ValidationError: diff --git a/l10n_br_nfe/models/__init__.py b/l10n_br_nfe/models/__init__.py index e8a09f1a5adc..7222c6a3032b 100644 --- a/l10n_br_nfe/models/__init__.py +++ b/l10n_br_nfe/models/__init__.py @@ -21,3 +21,6 @@ from . import invalidate_number from . import dfe from . import mde + +spec_schema = "nfe" +spec_version = "40" diff --git a/l10n_br_nfe/models/document.py b/l10n_br_nfe/models/document.py index 7f62f604fa9a..0855f2a5bdba 100644 --- a/l10n_br_nfe/models/document.py +++ b/l10n_br_nfe/models/document.py @@ -77,24 +77,19 @@ def filter_processador_edoc_nfe(record): class NFe(spec_models.StackedModel): _name = "l10n_br_fiscal.document" - _inherit = ["l10n_br_fiscal.document", "nfe.40.infnfe", "nfe.40.fat"] - _stacked = "nfe.40.infnfe" - _field_prefix = "nfe40_" - _schema_name = "nfe" - _schema_version = "4.0.0" - _odoo_module = "l10n_br_nfe" - _spec_module = "odoo.addons.l10n_br_nfe_spec.models.v4_0.leiaute_nfe_v4_00" - _spec_tab_name = "NFe" - _nfe_search_keys = ["nfe40_Id"] + _inherit = ["l10n_br_fiscal.document", "nfe.40.infnfe"] + _nfe40_odoo_module = "odoo.addons.l10n_br_nfe_spec.models.v4_0.leiaute_nfe_v4_00" + _nfe40_stacking_mixin = "nfe.40.infnfe" # all m2o at this level will be stacked even if not required: - _force_stack_paths = ( + _nfe40_stacking_force_paths = ( "infnfe.total", "infnfe.infAdic", "infnfe.exporta", "infnfe.cobr", "infnfe.cobr.fat", ) + _nfe_search_keys = ["nfe40_Id"] # When dynamic stacking is applied the NFe structure is: INFNFE_TREE = """ @@ -675,7 +670,7 @@ def _export_many2one(self, field_name, xsd_required, class_obj=None): denormalized inner attribute has been set. """ self.ensure_one() - if field_name in self._stacking_points.keys(): + if field_name in self._get_stacking_points().keys(): if field_name == "nfe40_ISSQNtot" and not any( t == "issqn" for t in self.nfe40_det.mapped("product_id.tax_icms_or_issqn") @@ -683,21 +678,23 @@ def _export_many2one(self, field_name, xsd_required, class_obj=None): return False elif (not xsd_required) and field_name not in ["nfe40_enderDest"]: - comodel = self.env[self._stacking_points.get(field_name).comodel_name] + comodel = self.env[ + self._get_stacking_points().get(field_name).comodel_name + ] fields = [ f for f in comodel._fields - if f.startswith(self._field_prefix) + if f.startswith(self._spec_prefix()) and f in self._fields.keys() and f # don't try to nfe40_fat id when reading nfe40_cobr for instance - not in self._stacking_points.keys() + not in self._get_stacking_points().keys() ] sub_tag_read = self.read(fields)[0] if not any( v for k, v in sub_tag_read.items() - if k.startswith(self._field_prefix) + if k.startswith(self._spec_prefix()) ): return False @@ -897,11 +894,11 @@ def _serialize(self, edocs): ): record.flush() record.invalidate_cache() - inf_nfe = record.export_ds()[0] + inf_nfe = record._build_binding("nfe", "40") inf_nfe_supl = None if record.nfe40_infNFeSupl: - inf_nfe_supl = record.nfe40_infNFeSupl.export_ds()[0] + inf_nfe_supl = record.nfe40_infNFeSupl._build_binding("nfe", "40") nfe = Nfe(infNFe=inf_nfe, infNFeSupl=inf_nfe_supl, signature=None) edocs.append(nfe) @@ -1070,9 +1067,13 @@ def _exec_after_SITUACAO_EDOC_AUTORIZADA(self, old_state, new_state): return super()._exec_after_SITUACAO_EDOC_AUTORIZADA(old_state, new_state) def _generate_key(self): - for record in self.filtered(filter_processador_edoc_nfe): - date = fields.Datetime.context_timestamp(record, record.document_date) + if self.document_type_id.code not in [ + MODELO_FISCAL_NFE, + MODELO_FISCAL_NFCE, + ]: + return super()._generate_key() + for record in self.filtered(filter_processador_edoc_nfe): required_fields_gen_edoc = [] if not record.company_cnpj_cpf: required_fields_gen_edoc.append("CNPJ/CPF") @@ -1090,6 +1091,7 @@ def _generate_key(self): _("To Generate EDoc Key, you need to fill the %s field.") % field ) + date = fields.Datetime.context_timestamp(record, record.document_date) chave_edoc = ChaveEdoc( ano_mes=date.strftime("%y%m").zfill(4), cnpj_cpf_emitente=record.company_cnpj_cpf, @@ -1346,7 +1348,7 @@ def import_binding_nfe(self, binding, edoc_type="out"): document = ( self.env["nfe.40.infnfe"] .with_context(tracking_disable=True, edoc_type=edoc_type, dry_run=False) - .build_from_binding(binding.NFe.infNFe) + .build_from_binding("nfe", "40", binding.NFe.infNFe) ) if edoc_type == "in" and document.company_id.cnpj_cpf != cnpj_cpf.formata( diff --git a/l10n_br_nfe/models/document_line.py b/l10n_br_nfe/models/document_line.py index b3d9bf801841..6952943f5037 100644 --- a/l10n_br_nfe/models/document_line.py +++ b/l10n_br_nfe/models/document_line.py @@ -70,17 +70,12 @@ class NFeLine(spec_models.StackedModel): _name = "l10n_br_fiscal.document.line" _inherit = ["l10n_br_fiscal.document.line", "nfe.40.det"] - _stacked = "nfe.40.det" - _field_prefix = "nfe40_" - _schema_name = "nfe" - _schema_version = "4.0.0" - _odoo_module = "l10n_br_nfe" - _spec_module = "odoo.addons.l10n_br_nfe_spec.models.v4_0.leiaute_nfe_v4_00" - _spec_tab_name = "NFe" - _stacking_points = {} + + _nfe40_odoo_module = "odoo.addons.l10n_br_nfe_spec.models.v4_0.leiaute_nfe_v4_00" + _nfe40_stacking_mixin = "nfe.40.det" # all m2o below this level will be stacked even if not required: - _force_stack_paths = ("det.imposto.",) - _stack_skip = ("nfe40_det_infNFe_id",) + _nfe40_stacking_force_paths = ("det.imposto.",) + _nfe40_stacking_skip_paths = ("nfe40_det_infNFe_id",) # When dynamic stacking is applied, the NFe line has the following structure: DET_TREE = """ @@ -526,7 +521,7 @@ def _export_fields_nfe_40_icms(self, xsd_fields, class_obj, export_dict): .replace("ICMS", "Icms") .replace("IcmsSN", "Icmssn") ) - binding_module = sys.modules[self._binding_module] + binding_module = sys.modules[self._get_spec_property("binding_module")] # Tnfe.InfNfe.Det.Imposto.Icms.Icms00 # see https://stackoverflow.com/questions/31174295/ # getattr-and-setattr-on-nested-subobjects-chained-properties diff --git a/l10n_br_nfe/models/document_related.py b/l10n_br_nfe/models/document_related.py index ad5e8b3e1124..6c3f0881e9a3 100644 --- a/l10n_br_nfe/models/document_related.py +++ b/l10n_br_nfe/models/document_related.py @@ -20,15 +20,11 @@ class NFeRelated(spec_models.StackedModel): _name = "l10n_br_fiscal.document.related" _inherit = ["l10n_br_fiscal.document.related", "nfe.40.nfref"] - _stacked = "nfe.40.nfref" - _field_prefix = "nfe40_" - _schema_name = "nfe" - _schema_version = "4.0.0" - _odoo_module = "l10n_br_nfe" - _spec_module = "odoo.addons.l10n_br_nfe_spec.models.v4_0.leiaute_nfe_v4_00" - _spec_tab_name = "NFe" - _stack_skip = ("nfe40_NFref_ide_id",) + + _nfe40_odoo_module = "odoo.addons.l10n_br_nfe_spec.models.v4_0.leiaute_nfe_v4_00" + _nfe40_stacking_mixin = "nfe.40.nfref" # all m2o below this level will be stacked even if not required: + _nfe40_stacking_skip_paths = ("nfe40_NFref_ide_id",) _rec_name = "nfe40_refNFe" # When dynamic stacking is applied, this class has the following structure: diff --git a/l10n_br_nfe/models/document_supplement.py b/l10n_br_nfe/models/document_supplement.py index 85590853eec7..1b875ea694b4 100644 --- a/l10n_br_nfe/models/document_supplement.py +++ b/l10n_br_nfe/models/document_supplement.py @@ -9,10 +9,6 @@ class NFeSupplement(spec_models.StackedModel): _name = "l10n_br_fiscal.document.supplement" _description = "NFe Supplement Document" _inherit = "nfe.40.infnfesupl" - _stacked = "nfe.40.infnfesupl" - _field_prefix = "nfe40_" - _schema_name = "nfe" - _schema_version = "4.0.0" - _odoo_module = "l10n_br_nfe" - _spec_module = "odoo.addons.l10n_br_nfe_spec.models.v4_0.leiaute_nfe_v4_00" - _spec_tab_name = "NFe" + + _nfe40_odoo_module = "odoo.addons.l10n_br_nfe_spec.models.v4_0.leiaute_nfe_v4_00" + _nfe40_stacking_mixin = "nfe.40.infnfesupl" diff --git a/l10n_br_nfe/tests/test_nfe_import.py b/l10n_br_nfe/tests/test_nfe_import.py index 8d9557b4d2cd..3933d11f91bc 100644 --- a/l10n_br_nfe/tests/test_nfe_import.py +++ b/l10n_br_nfe/tests/test_nfe_import.py @@ -11,11 +11,6 @@ class NFeImportTest(TransactionCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.env["spec.mixin.nfe"]._register_hook() - def test_import_in_nfe_dry_run(self): res_items = ( "nfe", @@ -32,7 +27,7 @@ def test_import_in_nfe_dry_run(self): nfe = ( self.env["nfe.40.infnfe"] .with_context(tracking_disable=True, edoc_type="in") - .build_from_binding(binding.NFe.infNFe, dry_run=True) + .build_from_binding("nfe", "40", binding.NFe.infNFe, dry_run=True) ) assert isinstance(nfe.id, NewId) self._check_nfe(nfe) @@ -51,7 +46,7 @@ def test_import_in_nfe(self): nfe = ( self.env["nfe.40.infnfe"] .with_context(tracking_disable=True, edoc_type="in") - .build_from_binding(binding.NFe.infNFe, dry_run=False) + .build_from_binding("nfe", "40", binding.NFe.infNFe, dry_run=False) ) assert isinstance(nfe.id, int) diff --git a/l10n_br_nfe/tests/test_nfe_serialize.py b/l10n_br_nfe/tests/test_nfe_serialize.py index c0011bf661b8..73cd1717c613 100644 --- a/l10n_br_nfe/tests/test_nfe_serialize.py +++ b/l10n_br_nfe/tests/test_nfe_serialize.py @@ -18,7 +18,6 @@ class TestNFeExport(TransactionCase): def setUp(self, nfe_list): super().setUp() - self.env["spec.mixin.nfe"]._register_hook() self.nfe_list = nfe_list for nfe_data in self.nfe_list: nfe = self.env.ref(nfe_data["record_ref"]) @@ -39,6 +38,7 @@ def prepare_test_nfe(self, nfe): line._onchange_fiscal_operation_line_id() nfe._compute_amount() + nfe._register_hook() # required in v16 for next statement nfe.nfe40_detPag = [ (5, 0, 0), ( diff --git a/l10n_br_nfe/tests/test_nfe_structure.py b/l10n_br_nfe/tests/test_nfe_structure.py index 20441ea93694..d067257b9333 100644 --- a/l10n_br_nfe/tests/test_nfe_structure.py +++ b/l10n_br_nfe/tests/test_nfe_structure.py @@ -16,7 +16,6 @@ class NFeStructure(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.env["spec.mixin.nfe"]._register_hook() @classmethod def get_stacked_tree(cls, klass): @@ -26,11 +25,25 @@ def get_stacked_tree(cls, klass): # ≡ means o2m. Eventually followd by the mapped Odoo model """ spec_module = "odoo.addons.l10n_br_nfe_spec.models.v4_0.leiaute_nfe_v4_00" - node = SpecModel._odoo_name_to_class(klass._stacked, spec_module) + spec_prefix = "nfe40" + stacking_settings = { + "odoo_module": getattr(klass, f"_{spec_prefix}_odoo_module"), + "stacking_mixin": getattr(klass, f"_{spec_prefix}_stacking_mixin"), + "stacking_points": getattr(klass, f"_{spec_prefix}_stacking_points"), + "stacking_skip_paths": getattr( + klass, f"_{spec_prefix}_stacking_skip_paths", [] + ), + "stacking_force_paths": getattr( + klass, f"_{spec_prefix}_stacking_force_paths", [] + ), + } + node = SpecModel._odoo_name_to_class( + stacking_settings["stacking_mixin"], spec_module + ) tree = StringIO() visited = set() for kind, n, path, field_path, child_concrete in klass._visit_stack( - cls.env, node + cls.env, node, stacking_settings ): visited.add(n) path_items = path.split(".") @@ -110,16 +123,19 @@ def test_doc_stacking_points(self): "nfe40_ide", "nfe40_infAdic", "nfe40_pag", - "nfe40_refECF", - "nfe40_refNF", - "nfe40_refNFP", "nfe40_retTrib", "nfe40_total", "nfe40_transp", "nfe40_cobr", "nfe40_fat", ] - keys = [k for k in self.env["l10n_br_fiscal.document"]._stacking_points.keys()] + keys = [ + k + for k in self.env["l10n_br_fiscal.document"] + .with_context(spec_schema="nfe", spec_version="40") + ._get_stacking_points() + .keys() + ] self.assertEqual(sorted(keys), sorted(doc_keys)) def test_doc_tree(self): @@ -155,7 +171,11 @@ def test_doc_line_stacking_points(self): "nfe40_prod", ] keys = [ - k for k in self.env["l10n_br_fiscal.document.line"]._stacking_points.keys() + k + for k in self.env["l10n_br_fiscal.document.line"] + .with_context(spec_schema="nfe", spec_version="40") + ._get_stacking_points() + .keys() ] self.assertEqual(sorted(keys), line_keys) diff --git a/l10n_br_nfe_spec/models/__init__.py b/l10n_br_nfe_spec/models/__init__.py index 1d382931ae2d..3140ceedcffa 100644 --- a/l10n_br_nfe_spec/models/__init__.py +++ b/l10n_br_nfe_spec/models/__init__.py @@ -1,2 +1,2 @@ -from . import spec_models +from . import spec_mixin from . import v4_0 diff --git a/l10n_br_nfe_spec/models/spec_models.py b/l10n_br_nfe_spec/models/spec_mixin.py similarity index 67% rename from l10n_br_nfe_spec/models/spec_models.py rename to l10n_br_nfe_spec/models/spec_mixin.py index dd9206bc853d..3eef958a594a 100644 --- a/l10n_br_nfe_spec/models/spec_models.py +++ b/l10n_br_nfe_spec/models/spec_mixin.py @@ -1,4 +1,4 @@ -# Copyright 2019-2020 Akretion - Raphael Valyi +# Copyright 2019-TODAY Akretion - Raphaël Valyi # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). from odoo import fields, models @@ -7,13 +7,8 @@ class NfeSpecMixin(models.AbstractModel): _description = "Abstract Model" _name = "spec.mixin.nfe" - _field_prefix = "nfe40_" - _schema_name = "nfe" - _schema_version = "4.0.0" - _odoo_module = "l10n_br_nfe" - _spec_module = "odoo.addons.l10n_br_nfe_spec.models.v4_0.leiaute_nfe_v4_00" - _binding_module = "nfelib.nfe.bindings.v4_0.leiaute_nfe_v4_00" - _spec_tab_name = "NFe" + _nfe40_odoo_module = "odoo.addons.l10n_br_nfe_spec.models.v4_0.leiaute_nfe_v4_00" + _nfe40_binding_module = "nfelib.nfe.bindings.v4_0.leiaute_nfe_v4_00" brl_currency_id = fields.Many2one( comodel_name="res.currency", diff --git a/l10n_br_nfe_spec/tests/test_nfe_import.py b/l10n_br_nfe_spec/tests/test_nfe_import.py index 69238e0f4702..023ecb2e05fe 100644 --- a/l10n_br_nfe_spec/tests/test_nfe_import.py +++ b/l10n_br_nfe_spec/tests/test_nfe_import.py @@ -15,7 +15,7 @@ from odoo.tests import TransactionCase from odoo.tools import OrderedSet -from ..models import spec_models +from ..models import spec_mixin tz_datetime = re.compile(r".*[-+]0[0-9]:00$") @@ -38,10 +38,7 @@ def build_attrs_fake(self, node, create_m2o=False): value = getattr(node, fname) if value is None: continue - key = "%s%s" % ( - self._field_prefix, - fspec.metadata.get("name", fname), - ) + key = f"nfe40_{fspec.metadata.get('name', fname)}" if ( fspec.type == str or not any(["." in str(i) for i in fspec.type.__args__]) ) and not str(fspec.type).startswith("typing.List"): @@ -66,12 +63,8 @@ def build_attrs_fake(self, node, create_m2o=False): key = fields[key]["related"][0] comodel_name = fields[key]["relation"] else: - clean_type = binding_type.lower() # TODO double check - comodel_name = "%s.%s.%s" % ( - self._schema_name, - self._schema_version.replace(".", "")[0:2], - clean_type.split(".")[-1], - ) + clean_type = binding_type.lower() + comodel_name = f"nfe.40.{clean_type.split('.')[-1]}" comodel = self.env.get(comodel_name) if comodel is None: # example skip ICMS100 class continue @@ -114,9 +107,9 @@ def match_or_create_m2o_fake(self, comodel, new_value, create_m2o=False): return comodel.new(new_value)._ids[0] -spec_models.NfeSpecMixin.build_fake = build_fake -spec_models.NfeSpecMixin.build_attrs_fake = build_attrs_fake -spec_models.NfeSpecMixin.match_or_create_m2o_fake = match_or_create_m2o_fake +spec_mixin.NfeSpecMixin.build_fake = build_fake +spec_mixin.NfeSpecMixin.build_attrs_fake = build_attrs_fake +spec_mixin.NfeSpecMixin.match_or_create_m2o_fake = match_or_create_m2o_fake # in version 12, 13 and 14, the code above would properly allow loading NFe XMLs @@ -143,7 +136,7 @@ def fields_convert_to_cache(self, value, record, validate=True): # cache format: tuple(ids) if isinstance(value, BaseModel): if validate and value._name != self.comodel_name: - raise ValueError("Wrong value for %s: %s" % (self, value)) + raise ValueError(f"Wrong value for {self}: {value}") ids = value._ids if record and not record.id: # x2many field value of new record is new records @@ -157,6 +150,7 @@ def fields_convert_to_cache(self, value, record, validate=True): # THE NEXT LINE WAS PATCHED: if record and hasattr(record, "id") and not record.id: browse = lambda it: comodel.browse([it and NewId(it)]) + else: browse = comodel.browse # determine the value ids @@ -195,7 +189,7 @@ def fields_convert_to_cache(self, value, record, validate=True): elif not value: return () - raise ValueError("Wrong value for %s: %s" % (self, value)) + raise ValueError(f"Wrong value for {self}: {value}") fields_convert_to_cache._original_method = fields._RelationalMulti.convert_to_cache @@ -215,7 +209,7 @@ def models_update_cache(self, values, validate=True): try: field_values = [(fields[name], value) for name, value in values.items()] except KeyError as e: - raise ValueError("Invalid field %r on model %r" % (e.args[0], self._name)) + raise ValueError(f"Invalid field {e.args[0]} on model {self._name}") from e # convert monetary fields after other columns for correct value rounding for field, value in sorted(field_values, key=lambda item: item[0].write_sequence): diff --git a/spec_driven_model/__manifest__.py b/spec_driven_model/__manifest__.py index 885597b722a3..4dded68f3697 100644 --- a/spec_driven_model/__manifest__.py +++ b/spec_driven_model/__manifest__.py @@ -3,12 +3,11 @@ { "name": "Spec Driven Model", - "summary": """ - Tools for specifications driven mixins (from xsd for instance)""", + "summary": """XML binding for Odoo: XML to Odoo models and models to XML.""", "version": "15.0.1.3.1", "maintainers": ["rvalyi"], "license": "LGPL-3", - "author": "Akretion,Odoo Community Association (OCA)", + "author": "Akretion, Odoo Community Association (OCA)", "website": "https://github.com/OCA/l10n-brazil", "depends": [], "data": [], diff --git a/spec_driven_model/models/spec_export.py b/spec_driven_model/models/spec_export.py index aa553814352c..e29061ec8128 100644 --- a/spec_driven_model/models/spec_export.py +++ b/spec_driven_model/models/spec_export.py @@ -1,5 +1,6 @@ # Copyright 2019 KMEE # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + import logging import sys @@ -14,7 +15,7 @@ class SpecMixinExport(models.AbstractModel): @api.model def _get_binding_class(self, class_obj): - binding_module = sys.modules[self._binding_module] + binding_module = sys.modules[self._get_spec_property("binding_module")] for attr in class_obj._binding_type.split("."): binding_module = getattr(binding_module, attr) return binding_module @@ -32,7 +33,7 @@ def _get_spec_classes(self, classes=False): for c in set(classes): if c is None: continue - if not c.startswith("%s." % (self._schema_name,)): + if not c.startswith(f"{self._context['spec_schema']}."): continue # the following filter to fields to show # when several XSD class are injected in the same object @@ -51,7 +52,7 @@ def _export_fields(self, xsd_fields, class_obj, export_dict): 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 + flow of fields to export or injecting specific values in the field export. """ self.ensure_one() @@ -70,9 +71,9 @@ def _export_fields(self, xsd_fields, class_obj, export_dict): continue if ( not self._fields.get(xsd_field) - ) and xsd_field not in self._stacking_points.keys(): + ) and xsd_field not in self._get_stacking_points().keys(): continue - field_spec_name = xsd_field.replace(class_obj._field_prefix, "") + field_spec_name = xsd_field.split("_")[1] # remove schema prefix field_spec = False for fname, fspec in binding_class_spec.items(): if fspec.metadata.get("name", {}) == field_spec_name: @@ -90,7 +91,7 @@ def _export_fields(self, xsd_fields, class_obj, export_dict): 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 xsd_field in self._get_stacking_points().keys(): if not field_data: # stacked nested tags are skipped if empty continue @@ -106,20 +107,18 @@ def _export_field(self, xsd_field, class_obj, field_spec, export_value=None): """ self.ensure_one() # TODO: Export number required fields with Zero. - field = class_obj._fields.get(xsd_field, self._stacking_points.get(xsd_field)) + field = class_obj._fields.get( + xsd_field, self._get_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 ( + if (not self._get_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": @@ -133,32 +132,32 @@ def _export_field(self, xsd_field, class_obj, field_spec, export_value=None): and self[xsd_field] is not False ): if hasattr(field, "xsd_choice_required"): - xsd_required = True # NOTE compat, see previous NOTE + xsd_required = True return self._export_float_monetary( xsd_field, xsd_type, class_obj, xsd_required, export_value ) - elif type(self[xsd_field]) is str: + elif isinstance(self[xsd_field], 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 + if field_name in self._get_stacking_points().keys(): + return self._build_binding( + class_name=self._get_stacking_points()[field_name].comodel_name ) else: - return (self[field_name] or self)._build_generateds( - class_obj._fields[field_name].comodel_name + return (self[field_name] or self)._build_binding( + class_name=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 + field_data = relational_field._build_binding( + class_name=class_obj._fields[field_name].comodel_name ) relational_data.append(field_data) return relational_data @@ -175,7 +174,7 @@ def _export_float_monetary( tdec = "".join(filter(lambda x: x.isdigit(), xsd_type))[-2:] else: tdec = "" - my_format = "%.{}f".format(tdec) + my_format = f"%.{tdec}f" return str(my_format % field_data) def _export_date(self, field_name): @@ -190,10 +189,10 @@ def _export_datetime(self, field_name): ).isoformat("T") ) - def _build_generateds(self, class_name=False): + def _build_binding(self, spec_schema=None, spec_version=None, class_name=None): """ Iterate over an Odoo record and its m2o and o2m sub-records - using a pre-order tree traversal and maps the Odoo record values + using a pre-order tree traversal and map the Odoo record values to a dict of Python binding values. These values will later be injected as **kwargs in the proper XML Python @@ -201,42 +200,27 @@ def _build_generateds(self, class_name=False): sub binding instances already properly instanciated. """ self.ensure_one() + if spec_schema and spec_version: + self = self.with_context(spec_schema=spec_schema, spec_version=spec_version) + self.env[f"spec.mixin.{spec_schema}"]._register_hook() if not class_name: - if hasattr(self, "_stacked"): - class_name = self._stacked - else: - class_name = self._name + class_name = self._get_spec_property("stacking_mixin", 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) + if class_obj._fields[i].name.startswith(f"{self._spec_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): - self.ensure_one() - result = [] - - if hasattr(self, "_stacked"): - binding_instance = self._build_generateds() - result.append(binding_instance) - return result - - def export_ds(self): # TODO rename export_binding! - self.ensure_one() - return self.export_xml() + sliced_kwargs = { + key: kwargs.get(key) + for key in binding_class.__dataclass_fields__.keys() + if kwargs.get(key) + } + return binding_class(**sliced_kwargs) diff --git a/spec_driven_model/models/spec_import.py b/spec_driven_model/models/spec_import.py index 40d595574fb7..bcea403c703d 100644 --- a/spec_driven_model/models/spec_import.py +++ b/spec_driven_model/models/spec_import.py @@ -21,17 +21,17 @@ 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. + xsdata 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): + def build_from_binding(self, spec_schema, spec_version, 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. + xsdata 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 @@ -42,8 +42,12 @@ def build_from_binding(self, node, dry_run=False): Defaults values and control options are meant to be passed in the context. """ + self = self.with_context( + spec_schema=spec_schema, spec_version=spec_version, dry_run=dry_run + ) + self._register_hook() model = self._get_concrete_model(self._name) - attrs = model.with_context(dry_run=dry_run).build_attrs(node) + attrs = model.build_attrs(node) if dry_run: return model.new(attrs) else: @@ -69,11 +73,9 @@ def _build_attr(self, node, fields, vals, path, attr): 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) + prefix = f"{self._spec_prefix()}" + key = f"{prefix}_{attr[1].metadata.get('name', attr[0])}" + child_path = f"{path}.{key}" # Is attr a xsd SimpleType or a ComplexType? # with xsdata a ComplexType can have a type like: @@ -118,9 +120,9 @@ def _build_attr(self, node, fields, vals, path, attr): 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], + comodel_name = "{}.{}.{}".format( + self._context["spec_schema"], + self._context["spec_version"].replace(".", "")[0:2], clean_type.split(".")[-1], ) @@ -194,9 +196,10 @@ def _prepare_import_dict( related_many2ones = {} fields = model._fields + field_prefix = f"{self._spec_prefix()}_" for k, v in fields.items(): # select schema choices for a friendly UI: - if k.startswith("%schoice" % (self._field_prefix,)): + if k.startswith(f"{field_prefix}choice"): for item in v.selection or []: if vals.get(item[0]) not in [None, []]: vals[k] = item[0] @@ -210,7 +213,7 @@ def _prepare_import_dict( related = v.related if len(related) == 1: vals[related[0]] = vals.get(k) - elif len(related) == 2 and k.startswith(self._field_prefix): + elif len(related) == 2 and k.startswith(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()): @@ -259,7 +262,7 @@ def match_record(self, rec_dict, parent_dict, model=None): if model is None: model = self default_key = [model._rec_name or "name"] - search_keys = "_%s_search_keys" % (self._schema_name) + search_keys = "_%s_search_keys" % (self._context["spec_schema"]) if hasattr(model, search_keys): keys = getattr(model, search_keys) + default_key else: @@ -277,8 +280,8 @@ def match_record(self, rec_dict, parent_dict, model=None): if match_ids: if len(match_ids) > 1: _logger.warning( - "!! WARNING more than 1 record found!! model: %s, domain: %s" - % (model, domain) + f"!! WARNING more than 1 record found!! model: {model}," + f" domain:{domain}" ) return match_ids[0].id return False diff --git a/spec_driven_model/models/spec_mixin.py b/spec_driven_model/models/spec_mixin.py index a31f6cb4b9af..7516f09fa962 100644 --- a/spec_driven_model/models/spec_mixin.py +++ b/spec_driven_model/models/spec_mixin.py @@ -1,6 +1,8 @@ -# Copyright 2019-2020 Akretion - Raphael Valyi +# Copyright 2019-TODAY Akretion - Raphael Valyi # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from importlib import import_module + from odoo import api, models from .spec_models import SPEC_MIXIN_MAPPINGS, SpecModel, StackedModel @@ -21,12 +23,7 @@ class SpecMixin(models.AbstractModel): _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' + _is_spec_driven = True def _valid_field_parameter(self, field, name): if name in ( @@ -48,26 +45,68 @@ def _get_concrete_model(self, model_name): else: return self.env.get(model_name) + def _spec_prefix(self, split=False): + """ + Get spec_schema and spec_version from context or from class module + """ + if self._context.get("spec_schema") and self._context.get("spec_version"): + spec_schema = self._context.get("spec_schema") + spec_version = self._context.get("spec_version") + if spec_schema and spec_version: + spec_version = spec_version.replace(".", "")[:2] + if split: + return spec_schema, spec_version + return f"{spec_schema}{spec_version}" + + for ancestor in type(self).mro(): + if not ancestor.__module__.startswith("odoo.addons."): + continue + mod = import_module(".".join(ancestor.__module__.split(".")[:-1])) + if hasattr(mod, "spec_schema"): + spec_schema = mod.spec_schema + spec_version = mod.spec_version.replace(".", "")[:2] + if split: + return spec_schema, spec_version + return f"{spec_schema}{spec_version}" + + return None, None if split else None + + def _get_spec_property(self, spec_property="", fallback=None): + """ + Used to access schema wise and version wise automatic mappings properties + """ + return getattr(self, f"_{self._spec_prefix()}_{spec_property}", fallback) + + def _get_stacking_points(self): + return self._get_spec_property("stacking_points", {}) + def _register_hook(self): """ Called once all modules are loaded. - Here we take all spec models that are not injected into existing concrete + Here we take all spec models that were not injected into existing concrete Odoo models and we make them concrete automatically with their _auto_init method that will create their SQL DDL structure. """ res = super()._register_hook() - if not hasattr(self, "_spec_module"): + spec_schema, spec_version = self._spec_prefix(split=True) + if not spec_schema: return res - load_key = "_%s_loaded" % (self._spec_module,) - if hasattr(self.env.registry, load_key): # already done for registry + spec_module = self._get_spec_property("odoo_module") + odoo_module = spec_module.split("_spec.")[0].split(".")[-1] + load_key = f"_{spec_module}_loaded" + if hasattr(self.env.registry, load_key): # hook already done for registry return res setattr(self.env.registry, load_key, True) + access_data = [] + access_fields = [] + field_prefix = f"{spec_schema}{spec_version}" + relation_prefix = f"{spec_schema}.{spec_version}.%" self.env.cr.execute( """SELECT DISTINCT relation FROM ir_model_fields WHERE relation LIKE %s;""", - (f"{self._schema_name}.{self._schema_version.replace('.', '')[:2]}.%",), + (relation_prefix,), ) # now we will filter only the spec models not injected into some existing class: remaining_models = { @@ -76,19 +115,14 @@ def _register_hook(self): if self.env.registry.get(i[0]) and not SPEC_MIXIN_MAPPINGS[self.env.cr.dbname].get(i[0]) } - for name in remaining_models: - spec_class = StackedModel._odoo_name_to_class(name, self._spec_module) + spec_class = StackedModel._odoo_name_to_class(name, spec_module) if spec_class is None: continue - spec_class._module = "fiscal" # TODO use python_module ? - fields = self.env[spec_class._name].fields_get_keys() + fields = self.env[spec_class._name]._fields rec_name = next( filter( - lambda x: ( - x.startswith(self.env[spec_class._name]._field_prefix) - and "_choice" not in x - ), + lambda x: (x.startswith(field_prefix) and "_choice" not in x), fields, ) ) @@ -98,14 +132,16 @@ def _register_hook(self): { "_name": name, "_inherit": spec_class._inherit, - "_original_module": "fiscal", - "_odoo_module": self._odoo_module, - "_spec_module": self._spec_module, + "_original_module": odoo_module, "_rec_name": rec_name, - "_module": self._odoo_module, + "_module": odoo_module, }, ) - models.MetaModel.module_to_models[self._odoo_module] += [model_type] + # we set _spec_schema and _spec_version because + # _build_model will not have context access: + model_type._spec_schema = spec_schema + model_type._spec_version = spec_version + models.MetaModel.module_to_models[odoo_module] += [model_type] # now we init these models properly # a bit like odoo.modules.loading#load_module_graph would do @@ -126,11 +162,11 @@ def _register_hook(self): "perm_create", "perm_unlink", ] - model._auto_fill_access_data(self.env, self._odoo_module, access_data) + model._auto_fill_access_data(self.env, odoo_module, access_data) self.env["ir.model.access"].load(access_fields, access_data) self.env.registry.init_models( - self.env.cr, remaining_models, {"module": self._odoo_module} + self.env.cr, remaining_models, {"module": odoo_module} ) return res @@ -141,10 +177,7 @@ def _auto_fill_access_data(cls, env, module_name: str, access_data: list): """ underline_name = cls._name.replace(".", "_") - model_id = "%s_spec.model_%s" % ( - module_name, - underline_name, - ) + model_id = f"{module_name}_spec.model_{underline_name}" user_access_name = f"access_{underline_name}_user" if not env["ir.model.access"].search( [ @@ -157,7 +190,7 @@ def _auto_fill_access_data(cls, env, module_name: str, access_data: list): user_access_name, user_access_name, model_id, - "%s.group_user" % (module_name,), + f"{module_name}.group_user", "1", "0", "0", @@ -176,7 +209,7 @@ def _auto_fill_access_data(cls, env, module_name: str, access_data: list): manager_access_name, manager_access_name, model_id, - "%s.group_manager" % (module_name,), + f"{module_name}.group_manager", "1", "1", "1", diff --git a/spec_driven_model/models/spec_models.py b/spec_driven_model/models/spec_models.py index 6faae882cb6a..48af64fdeb23 100644 --- a/spec_driven_model/models/spec_models.py +++ b/spec_driven_model/models/spec_models.py @@ -4,6 +4,7 @@ import logging import sys from collections import OrderedDict, defaultdict +from importlib import import_module from inspect import getmembers, isclass from odoo import SUPERUSER_ID, _, api, models @@ -63,7 +64,7 @@ def _compute_display_name(self): res = super()._compute_display_name() for rec in self: if rec.display_name == "False" or not rec.display_name: - rec.display_name = _("Abrir...") + rec.display_name = _("Open...") return res @classmethod @@ -74,11 +75,12 @@ def _build_model(cls, pool, cr): class as long as the generated spec mixins inherit from some spec.mixin. mixin. """ - schema = None - if hasattr(cls, "_schema_name"): - schema = cls._schema_name - elif pool.get(cls._name) and hasattr(pool[cls._name], "_schema_name"): - schema = pool[cls._name]._schema_name + if hasattr(cls, "_spec_schema"): # when called via _register_hook + schema = cls._spec_schema + else: + mod = import_module(".".join(cls.__module__.split(".")[:-1])) + schema = mod.spec_schema + if schema and "spec.mixin" not in [ c._name for c in pool[f"spec.mixin.{schema}"].__bases__ ]: @@ -111,14 +113,9 @@ def _setup_fields(self): 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] + cls = type(self) 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) - ): + if not hasattr(klass, "_is_spec_driven"): continue if klass._name != cls._name: cls._map_concrete(self.env.cr.dbname, klass._name, cls._name) @@ -176,7 +173,6 @@ def _setup_fields(self): @classmethod def _map_concrete(cls, dbname, key, target, quiet=False): - # TODO bookkeep according to a key to allow multiple injection contexts if not quiet: _logger.debug("%s ---> %s" % (key, target)) global SPEC_MIXIN_MAPPINGS @@ -212,8 +208,9 @@ class StackedModel(SpecModel): 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. + nodes starting from the stacking_mixin. stacking_skip_paths allows you to avoid + stacking specific nodes while stacking_force_paths will stack many2one + entities even if they are not required. In Brazil it allows us to have mostly the fiscal document objects and the fiscal document line object with many details @@ -225,40 +222,59 @@ class StackedModel(SpecModel): _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): + if hasattr(cls, "_spec_schema"): # when called via _register_hook + schema = cls._spec_schema + version = cls._spec_version.replace(".", "")[:2] + else: + mod = import_module(".".join(cls.__module__.split(".")[:-1])) + schema = mod.spec_schema + version = mod.spec_version.replace(".", "")[:2] + spec_prefix = f"{schema}{version}" + setattr(cls, f"_{spec_prefix}_stacking_points", {}) + stacking_settings = { + "odoo_module": getattr(cls, f"_{spec_prefix}_odoo_module"), # TODO inherit? + "stacking_mixin": getattr(cls, f"_{spec_prefix}_stacking_mixin"), + "stacking_points": getattr(cls, f"_{spec_prefix}_stacking_points"), + "stacking_skip_paths": getattr( + cls, f"_{spec_prefix}_stacking_skip_paths", [] + ), + "stacking_force_paths": getattr( + cls, f"_{spec_prefix}_stacking_force_paths", [] + ), + } # 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__ + _logger.info("building StackedModel %s %s" % (cls._name, cls)) + node = cls._odoo_name_to_class( + stacking_settings["stacking_mixin"], stacking_settings["odoo_module"] + ) + env = api.Environment(cr, SUPERUSER_ID, {}) + for kind, klass, _path, _field_path, _child_concrete in cls._visit_stack( + env, node, stacking_settings + ): + 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 + """ + Overriden to avoid adding many2one fields that are in fact "stacking points" + """ + if field.type == "many2one": + for cls in type(self).mro(): + if issubclass(cls, StackedModel): + for attr in dir(cls): + if attr != "_get_stacking_points" and attr.endswith( + "_stacking_points" + ): + if name in getattr(cls, attr).keys(): + return return super()._add_field(name, field) @classmethod - def _visit_stack(cls, env, node, path=None): + def _visit_stack(cls, env, node, stacking_settings, 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. @@ -270,8 +286,8 @@ def _visit_stack(cls, env, node, path=None): # 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(env.cr.dbname, node._name, cls._name, quiet=True) + path = stacking_settings["stacking_mixin"].split(".")[-1] + cls._map_concrete(env.cr.dbname, node._name, cls._name, quiet=True) yield "stacked", node, path, None, None fields = OrderedDict() @@ -294,14 +310,19 @@ def _visit_stack(cls, env, node, path=None): 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: + if f["type"] not in [ + "many2one", + "one2many", + ] or name in stacking_settings.get("stacking_skip_paths", ""): # TODO change for view or export continue - child = cls._odoo_name_to_class(f["comodel_name"], cls._spec_module) + child = cls._odoo_name_to_class( + f["comodel_name"], stacking_settings["odoo_module"] + ) if child is None: # Not a spec field continue child_concrete = SPEC_MIXIN_MAPPINGS[env.cr.dbname].get(child._name) - field_path = name.replace(env[node._name]._field_prefix, "") + field_path = name.split("_")[1] # remove schema prefix if f["type"] == "one2many": yield "one2many", node, path, field_path, child_concrete @@ -309,7 +330,7 @@ def _visit_stack(cls, env, node, path=None): force_stacked = any( stack_path in path + "." + field_path - for stack_path in cls._force_stack_paths + for stack_path in stacking_settings.get("stacking_force_paths", []) ) # many2one @@ -319,7 +340,9 @@ def _visit_stack(cls, env, node, path=None): # 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) + stacking_settings["stacking_points"][name] = env[ + node._name + ]._fields.get(name) + yield from cls._visit_stack(env, child, stacking_settings, 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 index a3cad41e326d..b8085eda44c1 100644 --- a/spec_driven_model/models/spec_view.py +++ b/spec_driven_model/models/spec_view.py @@ -133,7 +133,7 @@ def _build_spec_fragment(self, container=None): # 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""" + """Creates a view arch from an xsdata lib model arch""" # _logger.info("BUILD ARCH", lib_node) choices = set() wrapper_group = None diff --git a/spec_driven_model/readme/DESCRIPTION.rst b/spec_driven_model/readme/DESCRIPTION.rst index 9810adf5c3e4..5a4e3bc57048 100644 --- a/spec_driven_model/readme/DESCRIPTION.rst +++ b/spec_driven_model/readme/DESCRIPTION.rst @@ -1,7 +1,7 @@ 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 `_ +This module is a databinding framework for Odoo and XML data: it allows to go from XML to Odoo objects back and forth. While having no hard dependency with it, it has been designed to be used with 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! @@ -26,7 +26,7 @@ Now that you have generated these Odoo abstract bindings you should tell Odoo ho 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=`. +**Field mapping**: You can then define two ways mapping between fields by overriding fields from Odoo or from the binding 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. @@ -36,7 +36,7 @@ Notice you should inherit from `spec_models.SpecModel` and not the usual `models 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:: +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. Here is an example inspired from the Brazilian Electronic Invoice where the schema is called `nfe` and where we use the 2 digits `40` for its short version:: from odoo.addons.spec_driven_model.models import spec_models @@ -45,14 +45,21 @@ Sadly real life XML is a bit more complex than that. Often XML structures are de class InvoiceLine(spec_models.StackedModel): _inherit = [ 'account.move.line', - 'invoice.line.binding.mixin', + 'nfe.40.det', ] - _stacked = 'invoice.line.binding.mixin' + _nfe40_spec_settings = { + "module": "odoo.addons.l10n_br_nfe_spec.models.v4_0.leiaute_nfe_v4_00", + "stacking_mixin": "nfe.40.det", + "stacking_points": {}, + # all m2o below this level will be stacked even if not required: + "stacking_force_paths": ("det.imposto.",), + "stacking_skip_paths": ("nfe40_det_infNFe_id",), + } -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. +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 `stacking_force_paths` attribute. On the contrary, you can avoid some required many2one fields to be stacked using the `stacking_skip_paths` attribute. -Hooks -~~~~~ +Initialization hook +~~~~~~~~~~~~~~~~~~~ -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. +Because XSD schemas can define lot's of different models, spec_driven_model comes with a handy _register_hook 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/tests/__init__.py b/spec_driven_model/tests/__init__.py index c1e5e2a4f7a7..214f7be53ab6 100644 --- a/spec_driven_model/tests/__init__.py +++ b/spec_driven_model/tests/__init__.py @@ -1 +1,4 @@ from . import test_spec_model + +spec_schema = "poxsd" +spec_version = "10" diff --git a/spec_driven_model/tests/fake_mixin.py b/spec_driven_model/tests/fake_mixin.py index 7f3887edf35d..4d35973f70a9 100644 --- a/spec_driven_model/tests/fake_mixin.py +++ b/spec_driven_model/tests/fake_mixin.py @@ -7,12 +7,9 @@ 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" + + _poxsd10_odoo_module = "odoo.addons.spec_driven_model.tests.spec_poxsd" + _poxsd10_binding_module = "odoo.addons.spec_driven_model.tests.purchase_order_lib" # TODO rename brl_currency_id = fields.Many2one( diff --git a/spec_driven_model/tests/spec_purchase.py b/spec_driven_model/tests/spec_purchase.py index 9f01b44f760e..51c7d8aa1485 100644 --- a/spec_driven_model/tests/spec_purchase.py +++ b/spec_driven_model/tests/spec_purchase.py @@ -41,10 +41,9 @@ class PurchaseOrder(spec_models.StackedModel): _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_odoo_module = "odoo.addons.spec_driven_model.tests.spec_poxsd" + _poxsd10_stacking_mixin = "poxsd.10.purchaseordertype" poxsd10_orderDate = fields.Date(compute="_compute_date") poxsd10_confirmDate = fields.Date(related="date_approve") diff --git a/spec_driven_model/tests/test_spec_model.py b/spec_driven_model/tests/test_spec_model.py index 2658e8d6a216..7f5b98cf0268 100644 --- a/spec_driven_model/tests/test_spec_model.py +++ b/spec_driven_model/tests/test_spec_model.py @@ -82,18 +82,6 @@ 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( @@ -110,7 +98,11 @@ def test_spec_models(self): 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()) + set( + self.env["fake.purchase.order"] + ._poxsd10_stacking_points + .keys() + ) ) self.assertTrue( po_fields_or_stacking.issuperset( @@ -118,7 +110,11 @@ def test_stacked_model(self): ) ) self.assertEqual( - list(self.env["fake.purchase.order"]._stacking_points.keys()), + list( + self.env["fake.purchase.order"] + ._poxsd10_stacking_points + .keys() + ), ["poxsd10_items"], ) @@ -159,7 +155,11 @@ def test_create_export_import(self): # 2nd we serialize it into a binding object: # (that could be further XML serialized) - po_binding = po._build_generateds() + po_binding = po._build_binding(spec_schema="poxsd", spec_version="10") + self.assertEqual( + [s.__name__ for s in type(po_binding).mro()], + ["PurchaseOrderType", "object"], + ) 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) @@ -206,12 +206,14 @@ def test_create_export_import(self): # 4th 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 + "poxsd", "10", 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) + imported_po = self.env["fake.purchase.order"].build_from_binding( + "poxsd", "10", 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