From 6722ca2ecccdd4f8d5955315241528e3d81ed3ae Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 15 Nov 2024 10:43:35 +0100 Subject: [PATCH 1/7] edi_oca: consumer mixin add helper methods to send via edi --- edi_oca/models/edi_exchange_consumer_mixin.py | 111 ++++++++++++++++++ edi_oca/tests/test_consumer_mixin.py | 75 +++++++++++- 2 files changed, 184 insertions(+), 2 deletions(-) diff --git a/edi_oca/models/edi_exchange_consumer_mixin.py b/edi_oca/models/edi_exchange_consumer_mixin.py index 773b678bd4..7154e89845 100644 --- a/edi_oca/models/edi_exchange_consumer_mixin.py +++ b/edi_oca/models/edi_exchange_consumer_mixin.py @@ -293,3 +293,114 @@ def _edi_set_origin(self, exc_record): def _edi_get_origin(self): self.ensure_one() return self.origin_exchange_record_id + + # TODO: full unit test coverage + def _edi_send_via_edi(self, exchange_type, backend=None, force=False, **kw): + """Simply sending out a record via EDI. + + If the exchange type requires an ack, it will be generated + if not already present. + """ + exchange_record = None + # If we are sending an ack, we must check if we can generate it + if exchange_type.ack_for_type_ids: + # TODO: shall we raise an error if the ack is not possible? + if self._edi_can_generate_ack(exchange_type): + __, exchange_record = self._edi_get_or_create_ack_record( + exchange_type, force=force + ) + else: + exchange_record = self._edi_create_exchange_record( + exchange_type, backend=backend + ) + if exchange_record: + exchange_record.action_exchange_generate_send(**kw) + + # TODO: full unit test coverage + def _edi_can_generate_ack(self, exchange_type, force=False): + """Have to generate ack for this exchange type? + + :param exchange_type: The exchange type to check. + + It should be generated if: + - automation is not disabled and not forced + - origin exchange record is set (means it was originated by another record) + - origin exchange type is compatible with the configured ack types + """ + if (self.disable_edi_auto and not force) or not self.origin_exchange_record_id: + return False + return self.origin_exchange_type_id in exchange_type.ack_for_type_ids + + # TODO: full unit test coverage + def _edi_get_or_create_ack_record(self, exchange_type, backend=None, force=False): + """ + Get or create a child record for the given exchange type. + + If the record has not been sent out yet for whatever reason + (job delayed, job failed, send failed, etc) + we still want to generate a new up to date record to be sent. + + :param exchange_type: The exchange type to create the record for. + :param force: If True, will force the creation of the record in case of ack type. + """ + if not self._edi_can_generate_ack(exchange_type, force=force): + return False, False + parent = self._edi_get_origin() + # Filter acks that are not valued yet. + exchange_record = self._get_exchange_record(exchange_type).filtered( + lambda x: not x.exchange_file + ) + created = False + # If the record has not been sent out yet for whatever reason + # (job delayed, job failed, send failed, etc) + # we still want to generate a new up to date record to be sent. + still_pending = exchange_record.edi_exchange_state in ( + "output_pending", + "output_error_on_send", + ) + if not exchange_record or still_pending: + vals = exchange_record._exchange_child_record_values() + vals["parent_id"] = parent.id + # NOTE: to fully automatize this, + # is recommended to enable `quick_exec` on the type + # otherwise records will have to wait for the cron to pass by. + exchange_record = self._edi_create_exchange_record( + exchange_type, backend=backend, vals=vals + ) + created = True + return created, exchange_record + + # TODO: full unit test coverage + def _edi_send_via_email( + self, ir_action, subtype_ref=None, partner_method=None, partners=None + ): + """Send EDI file via email using the provided action.""" + # FIXME: missing generation of the record and adding it as an attachment + # In this case, the record should be generated immediately and attached to the email. + # An alternative is to generate the record and have a component to send via email. + + # Retrieve context and composer model + ctx = ir_action.get("context", {}) + composer_model = self.env[ir_action["res_model"]].with_context(ctx) + + # Determine subtype and partner_ids dynamically based on model-specific logic + subtype = subtype_ref and self.env.ref(subtype_ref) or None + if not subtype: + return False + + # THIS IS the part that should be delegated to a specific send component + # It could be also moved to its own module. + composer = composer_model.create({"subtype_id": subtype.id}) + composer.onchange_template_id_wrapper() + + # Dynamically retrieve partners based on the provided method or fallback to parameter + if partner_method and hasattr(self, partner_method): + composer.partner_ids = getattr(self, partner_method)().ids + elif partners: + composer.partner_ids = partners.ids + else: + return False + + # Send the email + composer.send_mail() + return True diff --git a/edi_oca/tests/test_consumer_mixin.py b/edi_oca/tests/test_consumer_mixin.py index c6feaf4406..c7109a05be 100644 --- a/edi_oca/tests/test_consumer_mixin.py +++ b/edi_oca/tests/test_consumer_mixin.py @@ -5,7 +5,7 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). import os -import unittest +from unittest import mock, skipIf from lxml import etree from odoo_test_helper import FakeModelLoader @@ -17,8 +17,8 @@ # This clashes w/ some setup (eg: run tests w/ pytest when edi_storage is installed) # If you still want to run `edi` tests w/ pytest when this happens, set this env var. -@unittest.skipIf(os.getenv("SKIP_EDI_CONSUMER_CASE"), "Consumer test case disabled.") @tagged("at_install", "-post_install") +@skipIf(os.getenv("SKIP_EDI_CONSUMER_CASE"), "Consumer test case disabled.") class TestConsumerMixinCase(EDIBackendCommonTestCase): @classmethod def _setup_records(cls): @@ -191,3 +191,74 @@ def test_form(self): form = etree.fromstring(f._view["arch"]) self.assertTrue(form.xpath("//field[@name='edi_has_form_config']")) self.assertTrue(form.xpath("//field[@name='edi_config']")) + + # Don't care about real data processing here + @mock.patch("odoo.addons.edi_oca.models.edi_backend.EDIBackend._validate_data") + @mock.patch("odoo.addons.edi_oca.models.edi_backend.EDIBackend._exchange_generate") + @mock.patch("odoo.addons.edi_oca.models.edi_backend.EDIBackend._exchange_send") + def test_edi_send_via_edi(self, mocked_send, mocked_generate, mocked_validate): + mocked_generate.return_value = "result" + self.assertEqual(self.consumer_record.exchange_record_count, 0) + self.consumer_record._edi_send_via_edi( + self.exchange_type_new, backend=self.backend + ) + self.assertEqual( + self.consumer_record.exchange_record_ids[0].type_id, self.exchange_type_new + ) + self.assertEqual( + self.consumer_record.exchange_record_ids[0]._get_file_content(), "result" + ) + + @mock.patch("odoo.addons.edi_oca.models.edi_backend.EDIBackend._validate_data") + @mock.patch("odoo.addons.edi_oca.models.edi_backend.EDIBackend._exchange_generate") + @mock.patch("odoo.addons.edi_oca.models.edi_backend.EDIBackend._exchange_send") + def test_edi_send_via_edi_ack(self, mocked_send, mocked_generate, mocked_validate): + mocked_generate.return_value = "result" + vals = { + "model": self.consumer_record._name, + "res_id": self.consumer_record.id, + } + origin_exchange_record = self.backend.create_record( + self.exchange_type_in.code, vals + ) + origin_exchange_record._set_file_content("original file") + self.consumer_record._edi_set_origin(origin_exchange_record) + self.assertEqual(self.consumer_record.exchange_record_count, 1) + # Type out is an hack for the original record, they will be linked + self.exchange_type_in.ack_type_id = self.exchange_type_out + self.consumer_record._edi_send_via_edi( + self.exchange_type_out, backend=self.backend + ) + self.assertEqual(self.consumer_record.exchange_record_count, 2) + ack_record = self.consumer_record.exchange_record_ids[1] + self.assertEqual(ack_record.parent_id, origin_exchange_record) + self.assertEqual(ack_record.type_id, self.exchange_type_out) + self.assertEqual(ack_record._get_file_content(), "result") + + @mock.patch("odoo.addons.edi_oca.models.edi_backend.EDIBackend._validate_data") + @mock.patch("odoo.addons.edi_oca.models.edi_backend.EDIBackend._exchange_generate") + @mock.patch("odoo.addons.edi_oca.models.edi_backend.EDIBackend._exchange_send") + def test_edi_send_via_edi_invalid_ack( + self, mocked_send, mocked_generate, mocked_validate + ): + mocked_generate.return_value = "result" + vals = { + "model": self.consumer_record._name, + "res_id": self.consumer_record.id, + } + origin_exchange_record = self.backend.create_record( + self.exchange_type_in.code, vals + ) + origin_exchange_record._set_file_content("original file") + self.consumer_record._edi_set_origin(origin_exchange_record) + self.assertEqual(self.consumer_record.exchange_record_count, 1) + # Type out is an hack for another type, they will not be linked + self.exchange_type_in.ack_type_id = self.exchange_type_out_ack + self.consumer_record._edi_send_via_edi( + self.exchange_type_out, backend=self.backend + ) + self.assertEqual(self.consumer_record.exchange_record_count, 2) + ack_record = self.consumer_record.exchange_record_ids[1] + self.assertFalse(ack_record.parent_id) + self.assertEqual(ack_record.type_id, self.exchange_type_out) + self.assertEqual(ack_record._get_file_content(), "result") From cf13fefa542022db5b758abe00527f86125727bf Mon Sep 17 00:00:00 2001 From: thien Date: Thu, 18 Jul 2024 15:30:10 +0700 Subject: [PATCH 2/7] edi_oca: Add new model edi.configuration The aim of this model is to ease configuration for all kind of exchanges in particular at partner level. --- edi_oca/README.rst | 1 + edi_oca/__manifest__.py | 2 + edi_oca/data/edi_configuration.xml | 23 +++ edi_oca/models/__init__.py | 1 + edi_oca/models/edi_backend.py | 22 ++- edi_oca/models/edi_configuration.py | 209 ++++++++++++++++++++++ edi_oca/models/edi_exchange_record.py | 3 + edi_oca/readme/CONTRIBUTORS.rst | 1 + edi_oca/security/ir_model_access.xml | 18 ++ edi_oca/static/description/index.html | 12 +- edi_oca/tests/__init__.py | 1 + edi_oca/tests/fake_components.py | 26 +++ edi_oca/tests/fake_models.py | 67 ++++++- edi_oca/tests/test_edi_configuration.py | 163 +++++++++++++++++ edi_oca/views/edi_configuration_views.xml | 107 +++++++++++ edi_oca/views/menuitems.xml | 7 + 16 files changed, 648 insertions(+), 15 deletions(-) create mode 100644 edi_oca/data/edi_configuration.xml create mode 100644 edi_oca/models/edi_configuration.py create mode 100644 edi_oca/tests/test_edi_configuration.py create mode 100644 edi_oca/views/edi_configuration_views.xml diff --git a/edi_oca/README.rst b/edi_oca/README.rst index 3e07ad75c5..10ce0047ed 100644 --- a/edi_oca/README.rst +++ b/edi_oca/README.rst @@ -175,6 +175,7 @@ Contributors * Simone Orsi * Enric Tobella +* Thien Vo Maintainers ~~~~~~~~~~~ diff --git a/edi_oca/__manifest__.py b/edi_oca/__manifest__.py index 73b3ed8402..9863f05b2d 100644 --- a/edi_oca/__manifest__.py +++ b/edi_oca/__manifest__.py @@ -30,6 +30,7 @@ "data/sequence.xml", "data/job_channel.xml", "data/job_function.xml", + "data/edi_configuration.xml", "security/res_groups.xml", "security/ir_model_access.xml", "views/edi_backend_views.xml", @@ -37,6 +38,7 @@ "views/edi_exchange_record_views.xml", "views/edi_exchange_type_views.xml", "views/edi_exchange_type_rule_views.xml", + "views/edi_configuration_views.xml", "views/res_partner.xml", "views/menuitems.xml", "templates/exchange_chatter_msg.xml", diff --git a/edi_oca/data/edi_configuration.xml b/edi_oca/data/edi_configuration.xml new file mode 100644 index 0000000000..e9784a132c --- /dev/null +++ b/edi_oca/data/edi_configuration.xml @@ -0,0 +1,23 @@ + + + + + Send Via Email + False + on_send_via_email + on_send_via_email + record._edi_send_via_email() + + + + + Send Via EDI + False + on_send_via_edi + on_send_via_edi + record._edi_send_via_edi(conf.type_id) + + diff --git a/edi_oca/models/__init__.py b/edi_oca/models/__init__.py index f40b0abe1e..c5223ae7f6 100644 --- a/edi_oca/models/__init__.py +++ b/edi_oca/models/__init__.py @@ -5,3 +5,4 @@ from . import edi_exchange_type from . import edi_exchange_type_rule from . import edi_id_mixin +from . import edi_configuration diff --git a/edi_oca/models/edi_backend.py b/edi_oca/models/edi_backend.py index 427c435d61..6791a1f1d9 100644 --- a/edi_oca/models/edi_backend.py +++ b/edi_oca/models/edi_backend.py @@ -398,6 +398,19 @@ def _cron_check_output_exchange_sync(self, **kw): for backend in self: backend._check_output_exchange_sync(**kw) + def exchange_generate_send(self, recordset, skip_generate=False, skip_send=False): + for rec in recordset: + if skip_generate: + job1 = rec + else: + job1 = rec.delayable().action_exchange_generate() + if hasattr(job1, "on_done"): + if not skip_send: + # Chain send job. + # Raise prio to max to send the record out as fast as possible. + job1.on_done(rec.delayable(priority=0).action_exchange_send()) + job1.delay() + def _check_output_exchange_sync( self, skip_send=False, skip_sent=True, record_ids=None ): @@ -415,13 +428,8 @@ def _check_output_exchange_sync( "EDI Exchange output sync: found %d new records to process.", len(new_records), ) - for rec in new_records: - job1 = rec.delayable().action_exchange_generate() - if not skip_send: - # Chain send job. - # Raise prio to max to send the record out as fast as possible. - job1.on_done(rec.delayable(priority=0).action_exchange_send()) - job1.delay() + if new_records: + self.exchange_generate_send(new_records, skip_send=skip_send) if skip_send: return diff --git a/edi_oca/models/edi_configuration.py b/edi_oca/models/edi_configuration.py new file mode 100644 index 0000000000..d6b693b843 --- /dev/null +++ b/edi_oca/models/edi_configuration.py @@ -0,0 +1,209 @@ +# Copyright 2024 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import datetime + +import pytz + +from odoo import _, api, exceptions, fields, models +from odoo.tools import DotDict, safe_eval + + +def date_to_datetime(dt): + """Convert date to datetime.""" + if isinstance(dt, datetime.date): + return datetime.datetime.combine(dt, datetime.datetime.min.time()) + return dt + + +def to_utc(dt): + """Convert date or datetime to UTC.""" + # Gracefully convert to datetime if needed 1st + return date_to_datetime(dt).astimezone(pytz.UTC) + + +class EdiConfiguration(models.Model): + _name = "edi.configuration" + _description = """ + This model is used to configure EDI (Electronic Data Interchange) flows. + It allows users to create their own configurations, which can be tailored + to meet the specific needs of their business processes. + """ + + name = fields.Char(string="Name", required=True) + active = fields.Boolean(default=True) + code = fields.Char(required=True, copy=False, index=True, unique=True) + description = fields.Char(help="Describe what the conf is for") + backend_id = fields.Many2one(string="Backend", comodel_name="edi.backend") + # Field `type_id` is not a mandatory field because we will create 2 common confs + # for EDI (`send_via_email` and `send_via_edi`). So type_id is + # a mandatory field will create unwanted data for users when installing this module. + type_id = fields.Many2one( + string="Exchange Type", + comodel_name="edi.exchange.type", + ondelete="cascade", + auto_join=True, + index=True, + ) + model_id = fields.Many2one( + "ir.model", + string="Model", + help="Model the conf applies to. Leave blank to apply for all models", + ) + model_name = fields.Char(related="model_id.model", store=True) + trigger = fields.Selection( + # The selections below are intended to assist with basic operations + # and are used to setup common configuration. + [ + ("on_record_write", "Update Record"), + ("on_record_create", "Create Record"), + ("on_send_via_email", "Send Via Email"), + ("on_send_via_edi", "Send Via EDI"), + ("disabled", "Disabled"), + ], + string="Trigger", + # The default selection will be disabled. + # which would allow to keep the conf visible but disabled. + required=True, + default="disabled", + ondelete="on default", + ) + snippet_before_do = fields.Text( + string="Snippet Before Do", + help="Snippet to validate the state and collect records to do", + ) + snippet_do = fields.Text( + string="Snippet Do", + help="""Used to do something specific here. + Receives: operation, edi_action, vals, old_vals.""", + ) + + @api.constrains("backend_id", "type_id") + def _constrains_backend(self): + for rec in self: + if rec.type_id.backend_id: + if rec.type_id.backend_id != rec.backend_id: + raise exceptions.ValidationError( + _("Backend must match with exchange type's backend!") + ) + else: + if rec.type_id.backend_type_id != rec.backend_id.backend_type_id: + raise exceptions.ValidationError( + _("Backend type must match with exchange type's backend type!") + ) + + # TODO: This function is also available in `edi_exchange_template`. + # Consider adding this to util or mixin + def _code_snippet_valued(self, snippet): + snippet = snippet or "" + return bool( + [ + not line.startswith("#") + for line in (snippet.splitlines()) + if line.strip("") + ] + ) + + @staticmethod + def _date_to_string(dt, utc=True): + if not dt: + return "" + if utc: + dt = to_utc(dt) + return fields.Date.to_string(dt) + + @staticmethod + def _datetime_to_string(dt, utc=True): + if not dt: + return "" + if utc: + dt = to_utc(dt) + return fields.Datetime.to_string(dt) + + def _time_utils(self): + return { + "datetime": safe_eval.datetime, + "dateutil": safe_eval.dateutil, + "time": safe_eval.time, + "utc_now": fields.Datetime.now(), + "date_to_string": self._date_to_string, + "datetime_to_string": self._datetime_to_string, + "time_to_string": lambda dt: dt.strftime("%H:%M:%S") if dt else "", + "first_of": fields.first, + } + + def _get_code_snippet_eval_context(self): + """Prepare the context used when evaluating python code + + :returns: dict -- evaluation context given to safe_eval + """ + ctx = { + "uid": self.env.uid, + "user": self.env.user, + "DotDict": DotDict, + "conf": self, + } + ctx.update(self._time_utils()) + return ctx + + def _evaluate_code_snippet(self, snippet, **render_values): + if not self._code_snippet_valued(snippet): + return {} + eval_ctx = dict(render_values, **self._get_code_snippet_eval_context()) + safe_eval.safe_eval(snippet, eval_ctx, mode="exec", nocopy=True) + result = eval_ctx.get("result", {}) + if not isinstance(result, dict): + return {} + return result + + def edi_exec_snippet_before_do(self, record, **kwargs): + self.ensure_one() + # Execute snippet before do + vals_before_do = self._evaluate_code_snippet( + self.snippet_before_do, record=record, **kwargs + ) + + # Prepare data + vals = { + "todo": vals_before_do.get("todo", True), + "snippet_do_vars": vals_before_do.get("snippet_do_vars", False), + "event_only": vals_before_do.get("event_only", False), + "tracked_fields": vals_before_do.get("tracked_fields", False), + "edi_action": vals_before_do.get("edi_action", False), + } + return vals + + def edi_exec_snippet_do(self, record, **kwargs): + self.ensure_one() + if self.trigger == "disabled": + return False + + old_value = kwargs.get("old_vals", {}).get(record.id, {}) + new_value = kwargs.get("vals", {}).get(record.id, {}) + vals = { + "todo": True, + "record": record, + "operation": kwargs.get("operation", False), + "edi_action": kwargs.get("edi_action", False), + "old_value": old_value, + "vals": new_value, + } + if self.snippet_before_do: + before_do_vals = self.edi_exec_snippet_before_do(record, **kwargs) + vals.update(before_do_vals) + if vals["todo"]: + return self._evaluate_code_snippet(self.snippet_do, **vals) + return True + + def edi_get_conf(self, trigger, backend=None): + domain = [("trigger", "=", trigger)] + if backend: + domain.append(("backend_id", "=", backend.id)) + else: + # We will only get confs that have backend_id = False + # or are equal to self.type_id.backend_id.id + backend_ids = self.mapped("type_id.backend_id.id") + backend_ids.append(False) + domain.append(("backend_id", "in", backend_ids)) + return self.filtered_domain(domain) diff --git a/edi_oca/models/edi_exchange_record.py b/edi_oca/models/edi_exchange_record.py index 80109326c3..e3072813da 100644 --- a/edi_oca/models/edi_exchange_record.py +++ b/edi_oca/models/edi_exchange_record.py @@ -319,6 +319,9 @@ def action_exchange_generate(self, **kw): self.ensure_one() return self.backend_id.exchange_generate(self, **kw) + def action_exchange_generate_send(self, **kw): + return self.backend_id.exchange_generate_send(self, **kw) + def action_exchange_send(self): self.ensure_one() return self.backend_id.exchange_send(self) diff --git a/edi_oca/readme/CONTRIBUTORS.rst b/edi_oca/readme/CONTRIBUTORS.rst index 4945a3dc40..033aac7124 100644 --- a/edi_oca/readme/CONTRIBUTORS.rst +++ b/edi_oca/readme/CONTRIBUTORS.rst @@ -1,2 +1,3 @@ * Simone Orsi * Enric Tobella +* Thien Vo diff --git a/edi_oca/security/ir_model_access.xml b/edi_oca/security/ir_model_access.xml index 6a80a34bfa..e7ab397e2f 100644 --- a/edi_oca/security/ir_model_access.xml +++ b/edi_oca/security/ir_model_access.xml @@ -139,4 +139,22 @@ name="domain_force" >['|',('company_id','=',False),('company_id', 'in', company_ids)] + + access_edi_configuration manager + + + + + + + + + access_edi_configuration user + + + + + + + diff --git a/edi_oca/static/description/index.html b/edi_oca/static/description/index.html index 51fca4c66b..1ab91c7705 100644 --- a/edi_oca/static/description/index.html +++ b/edi_oca/static/description/index.html @@ -8,11 +8,10 @@ /* :Author: David Goodger (goodger@python.org) -:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ +:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $ :Copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. -Despite the name, some widely supported CSS2 features are used. See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to customize this style sheet. @@ -275,7 +274,7 @@ margin-left: 2em ; margin-right: 2em } -pre.code .ln { color: gray; } /* line numbers */ +pre.code .ln { color: grey; } /* line numbers */ pre.code, code { background-color: #eeeeee } pre.code .comment, code .comment { color: #5C6576 } pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } @@ -301,7 +300,7 @@ span.pre { white-space: pre } -span.problematic, pre.problematic { +span.problematic { color: red } span.section-subtitle { @@ -525,14 +524,13 @@

Contributors

Maintainers

This module is maintained by the OCA.

- -Odoo Community Association - +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.

diff --git a/edi_oca/tests/__init__.py b/edi_oca/tests/__init__.py index b4a8849793..435629de03 100644 --- a/edi_oca/tests/__init__.py +++ b/edi_oca/tests/__init__.py @@ -14,3 +14,4 @@ from . import test_quick_exec from . import test_exchange_type_deprecated_fields from . import test_exchange_type_encoding +from . import test_edi_configuration diff --git a/edi_oca/tests/fake_components.py b/edi_oca/tests/fake_components.py index f764c5e756..1602547f52 100644 --- a/edi_oca/tests/fake_components.py +++ b/edi_oca/tests/fake_components.py @@ -134,3 +134,29 @@ class FakeInputValidate(FakeComponentMixin): def validate(self, value=None): self._fake_it() return + + +class FakeConfigurationListener(FakeComponentMixin): + _name = "fake.configuration.listener" + _inherit = "base.event.listener" + _apply_on = ["edi.exchange.consumer.test"] + + def on_record_write_configuration(self, record, fields=None, **kwargs): + trigger = "on_record_write" + if kwargs.get("vals", False): + for rec in record: + confs = record.edi_config_ids.edi_get_conf(trigger) + for conf in confs: + conf.edi_exec_snippet_do(rec, **kwargs) + return True + + def on_record_create_configuration(self, record, fields=None, **kwargs): + trigger = "on_record_create" + val_list = kwargs.get("vals", False) + if val_list: + for rec, vals in zip(record, val_list): + kwargs["vals"] = {rec.id: vals} + confs = rec.edi_config_ids.edi_get_conf(trigger) + for conf in confs: + conf.edi_exec_snippet_do(rec, **kwargs) + return True diff --git a/edi_oca/tests/fake_models.py b/edi_oca/tests/fake_models.py index 44bd3b73fe..cb87890367 100644 --- a/edi_oca/tests/fake_models.py +++ b/edi_oca/tests/fake_models.py @@ -2,7 +2,7 @@ # @author: Enric Tobella # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -from odoo import fields, models +from odoo import api, fields, models class EdiExchangeConsumerTest(models.Model): @@ -11,6 +11,71 @@ class EdiExchangeConsumerTest(models.Model): _description = "Model used only for test" name = fields.Char() + edi_config_ids = fields.Many2many( + string="EDI Purchase Config Ids", + comodel_name="edi.configuration", + relation="test_edi_configuration_rel", + column1="record_id", + column2="conf_id", + domain="[('model_name', '=', 'edi.exchange.consumer.test')]", + ) def _get_edi_exchange_record_name(self, exchange_record): return self.id + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + operation = "create" + + new_records = self.browse() + new_vals_list = [] + + for rec, vals in zip(records, vals_list): + if not rec._edi_configuration_skip(operation): + new_records |= rec + new_vals_list.append(vals) + + if new_records: + self._event("on_record_create_configuration").notify( + new_records, + operation=operation, + vals=new_vals_list, + ) + return records + + def write(self, vals): + operation = "write" + new_records = self.browse() + + for rec in self: + if not rec._edi_configuration_skip(operation): + new_records |= rec + + old_vals = {} + for record in new_records: + old_vals[record.id] = {field: record[field] for field in vals.keys()} + + res = super().write(vals) + + new_values = {} + for record in new_records: + new_values[record.id] = {field: record[field] for field in vals.keys()} + + if new_values: + self._event("on_record_write_configuration").notify( + new_records, + operation=operation, + old_vals=old_vals, + vals=new_values, + ) + return res + + def _edi_configuration_skip(self, operation): + skip_reason = None + if self.env.context.get("edi_skip_configuration"): + skip_reason = "edi_skip_configuration ctx key found" + # TODO: Add more skip cases + if skip_reason: + return True + return False diff --git a/edi_oca/tests/test_edi_configuration.py b/edi_oca/tests/test_edi_configuration.py new file mode 100644 index 0000000000..36cfd386d2 --- /dev/null +++ b/edi_oca/tests/test_edi_configuration.py @@ -0,0 +1,163 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import os +import unittest + +from odoo_test_helper import FakeModelLoader + +from .common import EDIBackendCommonComponentRegistryTestCase +from .fake_components import ( + FakeConfigurationListener, + FakeOutputChecker, + FakeOutputGenerator, + FakeOutputSender, +) + + +# This clashes w/ some setup (eg: run tests w/ pytest when edi_storage is installed) +# If you still want to run `edi` tests w/ pytest when this happens, set this env var. +@unittest.skipIf(os.getenv("SKIP_EDI_CONSUMER_CASE"), "Consumer test case disabled.") +class TestEDIConfigurations(EDIBackendCommonComponentRegistryTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._build_components( + cls, + FakeOutputGenerator, + FakeOutputSender, + FakeOutputChecker, + FakeConfigurationListener, + ) + vals = { + "model": cls.partner._name, + "res_id": cls.partner.id, + } + cls.record = cls.backend.create_record("test_csv_output", vals) + + def setUp(self): + super().setUp() + FakeOutputGenerator.reset_faked() + FakeOutputSender.reset_faked() + FakeOutputChecker.reset_faked() + self.consumer_record = self.env["edi.exchange.consumer.test"].create( + { + "name": "Test Consumer", + "edi_config_ids": [ + (4, self.create_config.id), + (4, self.write_config.id), + ], + } + ) + + @classmethod + def _setup_records(cls): + super()._setup_records() + # Load fake models ->/ + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .fake_models import EdiExchangeConsumerTest + + cls.loader.update_registry((EdiExchangeConsumerTest,)) + cls.exchange_type_out.exchange_filename_pattern = "{record.id}" + cls.edi_configuration = cls.env["edi.configuration"] + cls.create_config = cls.edi_configuration.create( + { + "name": "Create Config", + "active": True, + "code": "create_config", + "backend_id": cls.backend.id, + "type_id": cls.exchange_type_out.id, + "trigger": "on_record_create", + "model_id": cls.env["ir.model"]._get_id("edi.exchange.consumer.test"), + "snippet_do": "record._edi_send_via_edi(conf.type_id)", + } + ) + cls.write_config = cls.edi_configuration.create( + { + "name": "Write Config 1", + "active": True, + "code": "write_config", + "backend_id": cls.backend.id, + "type_id": cls.exchange_type_out.id, + "trigger": "on_record_write", + "model_id": cls.env["ir.model"]._get_id("edi.exchange.consumer.test"), + "snippet_do": "record._edi_send_via_edi(conf.type_id)", + } + ) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super().tearDownClass() + + def test_edi_send_via_edi_config(self): + # Check configuration on create + self.consumer_record.refresh() + exchange_record = self.consumer_record.exchange_record_ids + self.assertEqual(len(exchange_record), 1) + self.assertEqual(exchange_record.type_id, self.exchange_type_out) + self.assertEqual(exchange_record.edi_exchange_state, "output_sent") + # Write the existed consumer record + self.consumer_record.name = "Fixed Consumer" + # check Configuration on write + self.consumer_record.refresh() + exchange_record = self.consumer_record.exchange_record_ids - exchange_record + self.assertEqual(len(exchange_record), 1) + self.assertEqual(exchange_record.type_id, self.exchange_type_out) + self.assertEqual(exchange_record.edi_exchange_state, "output_sent") + + def test_edi_code_snippet(self): + expected_value = { + "todo": True, + "snippet_do_vars": { + "a": 1, + "b": 2, + }, + "event_only": True, + "tracked_fields": ["state"], + "edi_action": "new_action", + } + # Simulate the snippet_before_do + self.write_config.snippet_before_do = "result = " + str(expected_value) + # Execute with the raw data + vals = self.write_config.edi_exec_snippet_before_do( + self.consumer_record, + tracked_fields=[], + edi_action="generate", + ) + # Check the new vals after execution + self.assertEqual(vals, expected_value) + + # Check the snippet_do + expected_value = { + "change_state": True, + "snippet_do_vars": { + "a": 1, + "b": 2, + }, + "record": self.consumer_record, + "tracked_fields": ["state"], + } + snippet_do = """\n +old_state = old_value.get("state", False)\n +new_state = vals.get("state", False)\n +result = {\n + "change_state": True if old_state and new_state and old_state != new_state else False,\n + "snippet_do_vars": snippet_do_vars,\n + "record": record,\n + "tracked_fields": tracked_fields,\n +} + """ + self.write_config.snippet_do = snippet_do + # Execute with the raw data + record_id = self.consumer_record.id + vals = self.write_config.edi_exec_snippet_do( + self.consumer_record, + tracked_fields=[], + edi_action="generate", + old_vals={record_id: dict(state="draft")}, + vals={record_id: dict(state="confirmed")}, + ) + # Check the new vals after execution + self.assertEqual(vals, expected_value) diff --git a/edi_oca/views/edi_configuration_views.xml b/edi_oca/views/edi_configuration_views.xml new file mode 100644 index 0000000000..fed81de213 --- /dev/null +++ b/edi_oca/views/edi_configuration_views.xml @@ -0,0 +1,107 @@ + + + + edi.configuration.view.search + edi.configuration + + + + + + + + + + + + + + edi.configuration + + + + + + + + + + + + + + + edi.configuration + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + EDI Configuration + ir.actions.act_window + edi.configuration + tree,form + [] + + + + + form + + + + + + tree + + +
diff --git a/edi_oca/views/menuitems.xml b/edi_oca/views/menuitems.xml index 3842809d31..9a2b1fd8f1 100644 --- a/edi_oca/views/menuitems.xml +++ b/edi_oca/views/menuitems.xml @@ -82,4 +82,11 @@ sequence="40" action="act_open_edi_exchange_type_rule_view" /> + From a51f0575f1e714f0b32744ddaf796aa351f5591d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 15 Nov 2024 09:36:57 +0100 Subject: [PATCH 3/7] edi_oca: edi.conf cleanup tests --- edi_oca/tests/fake_components.py | 22 +++++------- edi_oca/tests/fake_models.py | 59 +------------------------------- 2 files changed, 9 insertions(+), 72 deletions(-) diff --git a/edi_oca/tests/fake_components.py b/edi_oca/tests/fake_components.py index 1602547f52..449ded4b61 100644 --- a/edi_oca/tests/fake_components.py +++ b/edi_oca/tests/fake_components.py @@ -141,22 +141,16 @@ class FakeConfigurationListener(FakeComponentMixin): _inherit = "base.event.listener" _apply_on = ["edi.exchange.consumer.test"] - def on_record_write_configuration(self, record, fields=None, **kwargs): + def on_record_write(self, record, fields=None, **kwargs): trigger = "on_record_write" - if kwargs.get("vals", False): - for rec in record: - confs = record.edi_config_ids.edi_get_conf(trigger) - for conf in confs: - conf.edi_exec_snippet_do(rec, **kwargs) + confs = record.edi_config_ids.edi_get_conf(trigger) + for conf in confs: + conf.edi_exec_snippet_do(record, **kwargs) return True - def on_record_create_configuration(self, record, fields=None, **kwargs): + def on_record_create(self, record, fields=None, **kwargs): trigger = "on_record_create" - val_list = kwargs.get("vals", False) - if val_list: - for rec, vals in zip(record, val_list): - kwargs["vals"] = {rec.id: vals} - confs = rec.edi_config_ids.edi_get_conf(trigger) - for conf in confs: - conf.edi_exec_snippet_do(rec, **kwargs) + confs = record.edi_config_ids.edi_get_conf(trigger) + for conf in confs: + conf.edi_exec_snippet_do(record, **kwargs) return True diff --git a/edi_oca/tests/fake_models.py b/edi_oca/tests/fake_models.py index cb87890367..364c84faef 100644 --- a/edi_oca/tests/fake_models.py +++ b/edi_oca/tests/fake_models.py @@ -2,7 +2,7 @@ # @author: Enric Tobella # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -from odoo import api, fields, models +from odoo import fields, models class EdiExchangeConsumerTest(models.Model): @@ -22,60 +22,3 @@ class EdiExchangeConsumerTest(models.Model): def _get_edi_exchange_record_name(self, exchange_record): return self.id - - @api.model_create_multi - def create(self, vals_list): - records = super().create(vals_list) - operation = "create" - - new_records = self.browse() - new_vals_list = [] - - for rec, vals in zip(records, vals_list): - if not rec._edi_configuration_skip(operation): - new_records |= rec - new_vals_list.append(vals) - - if new_records: - self._event("on_record_create_configuration").notify( - new_records, - operation=operation, - vals=new_vals_list, - ) - return records - - def write(self, vals): - operation = "write" - new_records = self.browse() - - for rec in self: - if not rec._edi_configuration_skip(operation): - new_records |= rec - - old_vals = {} - for record in new_records: - old_vals[record.id] = {field: record[field] for field in vals.keys()} - - res = super().write(vals) - - new_values = {} - for record in new_records: - new_values[record.id] = {field: record[field] for field in vals.keys()} - - if new_values: - self._event("on_record_write_configuration").notify( - new_records, - operation=operation, - old_vals=old_vals, - vals=new_values, - ) - return res - - def _edi_configuration_skip(self, operation): - skip_reason = None - if self.env.context.get("edi_skip_configuration"): - skip_reason = "edi_skip_configuration ctx key found" - # TODO: Add more skip cases - if skip_reason: - return True - return False From 01303a07832eb011fcd53f4106ad5443174f9947 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 15 Nov 2024 09:39:14 +0100 Subject: [PATCH 4/7] edi_oca: add edi.configuration.trigger Ease configuration by filtering triggers by model. In the long run we'll have tons of triggers for specific scopes (sale, purchase, etc). This change will make sure the selection of the trigger won't be cluttered. It also adds the possibility to describe triggers as users prefer. --- edi_oca/__manifest__.py | 1 + edi_oca/data/edi_configuration.xml | 43 +++++++++++++----- edi_oca/models/__init__.py | 1 + edi_oca/models/edi_configuration.py | 21 +++------ edi_oca/models/edi_configuration_trigger.py | 24 ++++++++++ edi_oca/security/ir_model_access.xml | 18 ++++++++ edi_oca/tests/test_edi_configuration.py | 6 +-- .../views/edi_configuration_trigger_views.xml | 44 +++++++++++++++++++ edi_oca/views/edi_configuration_views.xml | 12 ++--- 9 files changed, 132 insertions(+), 38 deletions(-) create mode 100644 edi_oca/models/edi_configuration_trigger.py create mode 100644 edi_oca/views/edi_configuration_trigger_views.xml diff --git a/edi_oca/__manifest__.py b/edi_oca/__manifest__.py index 9863f05b2d..2def86a48e 100644 --- a/edi_oca/__manifest__.py +++ b/edi_oca/__manifest__.py @@ -39,6 +39,7 @@ "views/edi_exchange_type_views.xml", "views/edi_exchange_type_rule_views.xml", "views/edi_configuration_views.xml", + "views/edi_configuration_trigger_views.xml", "views/res_partner.xml", "views/menuitems.xml", "templates/exchange_chatter_msg.xml", diff --git a/edi_oca/data/edi_configuration.xml b/edi_oca/data/edi_configuration.xml index e9784a132c..8623f39ec7 100644 --- a/edi_oca/data/edi_configuration.xml +++ b/edi_oca/data/edi_configuration.xml @@ -4,20 +4,39 @@ Examlple: record._edi_send_via_email(ir_action) --> - - Send Via Email - False - on_send_via_email - on_send_via_email - record._edi_send_via_email() + + + On record create + on_record_create + Trigger when a record is created + + + On record write + on_record_write + Trigger when a record is updated + + + + Send via email + on_send_via_email + Send record via email TBD + + + Send via EDI + on_send_via_edi + Send record via EDI TBD - + + Send Via Email + False + + record._edi_send_via_email() + - Send Via EDI - False - on_send_via_edi - on_send_via_edi - record._edi_send_via_edi(conf.type_id) + Send Via EDI + False + + record._edi_send_via_edi(conf.type_id) diff --git a/edi_oca/models/__init__.py b/edi_oca/models/__init__.py index c5223ae7f6..4c3433a3a8 100644 --- a/edi_oca/models/__init__.py +++ b/edi_oca/models/__init__.py @@ -5,4 +5,5 @@ from . import edi_exchange_type from . import edi_exchange_type_rule from . import edi_id_mixin +from . import edi_configuration_trigger from . import edi_configuration diff --git a/edi_oca/models/edi_configuration.py b/edi_oca/models/edi_configuration.py index d6b693b843..0dbc880031 100644 --- a/edi_oca/models/edi_configuration.py +++ b/edi_oca/models/edi_configuration.py @@ -33,7 +33,6 @@ class EdiConfiguration(models.Model): name = fields.Char(string="Name", required=True) active = fields.Boolean(default=True) - code = fields.Char(required=True, copy=False, index=True, unique=True) description = fields.Char(help="Describe what the conf is for") backend_id = fields.Many2one(string="Backend", comodel_name="edi.backend") # Field `type_id` is not a mandatory field because we will create 2 common confs @@ -52,23 +51,13 @@ class EdiConfiguration(models.Model): help="Model the conf applies to. Leave blank to apply for all models", ) model_name = fields.Char(related="model_id.model", store=True) - trigger = fields.Selection( - # The selections below are intended to assist with basic operations - # and are used to setup common configuration. - [ - ("on_record_write", "Update Record"), - ("on_record_create", "Create Record"), - ("on_send_via_email", "Send Via Email"), - ("on_send_via_edi", "Send Via EDI"), - ("disabled", "Disabled"), - ], + trigger_id = fields.Many2one( string="Trigger", - # The default selection will be disabled. - # which would allow to keep the conf visible but disabled. - required=True, - default="disabled", - ondelete="on default", + comodel_name="edi.configuration.trigger", + help="Trigger that activates this configuration", + domain="['|', ('model_id', '=', model_id), ('model_id', '=', False)]", ) + trigger = fields.Char(related="trigger_id.code") snippet_before_do = fields.Text( string="Snippet Before Do", help="Snippet to validate the state and collect records to do", diff --git a/edi_oca/models/edi_configuration_trigger.py b/edi_oca/models/edi_configuration_trigger.py new file mode 100644 index 0000000000..81f18c16b4 --- /dev/null +++ b/edi_oca/models/edi_configuration_trigger.py @@ -0,0 +1,24 @@ +# Copyright 2024 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import fields, models + + +class EdiConfigurationTrigger(models.Model): + _name = "edi.configuration.trigger" + _description = """ + Describe what triggers a specific action for a configuration. + """ + + name = fields.Char(string="Name", required=True) + code = fields.Char(required=True, copy=False) + active = fields.Boolean(default=True) + description = fields.Char(help="Describe what the conf is for") + model_id = fields.Many2one( + "ir.model", + string="Model", + help="Model the conf applies to. Leave blank to apply for all models", + ) + + _sql_constraints = [("code_uniq", "unique(code)", "Code must be unique")] diff --git a/edi_oca/security/ir_model_access.xml b/edi_oca/security/ir_model_access.xml index e7ab397e2f..ad19d127c1 100644 --- a/edi_oca/security/ir_model_access.xml +++ b/edi_oca/security/ir_model_access.xml @@ -157,4 +157,22 @@ + + access_edi_configuration_trigger manager + + + + + + + + + access_edi_configuration_trigger user + + + + + + + diff --git a/edi_oca/tests/test_edi_configuration.py b/edi_oca/tests/test_edi_configuration.py index 36cfd386d2..d1264979bf 100644 --- a/edi_oca/tests/test_edi_configuration.py +++ b/edi_oca/tests/test_edi_configuration.py @@ -65,10 +65,9 @@ def _setup_records(cls): { "name": "Create Config", "active": True, - "code": "create_config", "backend_id": cls.backend.id, "type_id": cls.exchange_type_out.id, - "trigger": "on_record_create", + "trigger_id": cls.env.ref("edi_oca.edi_conf_trigger_record_create").id, "model_id": cls.env["ir.model"]._get_id("edi.exchange.consumer.test"), "snippet_do": "record._edi_send_via_edi(conf.type_id)", } @@ -77,10 +76,9 @@ def _setup_records(cls): { "name": "Write Config 1", "active": True, - "code": "write_config", "backend_id": cls.backend.id, "type_id": cls.exchange_type_out.id, - "trigger": "on_record_write", + "trigger_id": cls.env.ref("edi_oca.edi_conf_trigger_record_write").id, "model_id": cls.env["ir.model"]._get_id("edi.exchange.consumer.test"), "snippet_do": "record._edi_send_via_edi(conf.type_id)", } diff --git a/edi_oca/views/edi_configuration_trigger_views.xml b/edi_oca/views/edi_configuration_trigger_views.xml new file mode 100644 index 0000000000..e747a62b00 --- /dev/null +++ b/edi_oca/views/edi_configuration_trigger_views.xml @@ -0,0 +1,44 @@ + + + + edi.configuration.trigger + + + + + + + + + + + + edi.configuration.trigger + +
+ + + + + + + + + + + + + + +
+
+
+
diff --git a/edi_oca/views/edi_configuration_views.xml b/edi_oca/views/edi_configuration_views.xml index fed81de213..dd5de3fe9e 100644 --- a/edi_oca/views/edi_configuration_views.xml +++ b/edi_oca/views/edi_configuration_views.xml @@ -8,7 +8,7 @@ - + - - + @@ -53,14 +52,15 @@ - - - + Date: Thu, 28 Nov 2024 08:59:03 +0100 Subject: [PATCH 5/7] edi_oca: consumer mixin trigger state event Models using the consumer mixing and having a state field will now trigger a specific event when the state is updated. --- edi_oca/models/edi_exchange_consumer_mixin.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/edi_oca/models/edi_exchange_consumer_mixin.py b/edi_oca/models/edi_exchange_consumer_mixin.py index 7154e89845..513bb09a89 100644 --- a/edi_oca/models/edi_exchange_consumer_mixin.py +++ b/edi_oca/models/edi_exchange_consumer_mixin.py @@ -404,3 +404,20 @@ def _edi_send_via_email( # Send the email composer.send_mail() return True + + def write(self, vals): + # Generic event to match a state change + # TODO: this can be added to component_event for models having the state field + state_change = "state" in vals and "state" in self._fields + if state_change: + for rec in self: + rec._event(f"on_edi_{self._table}_before_state_change").notify( + rec, state=vals["state"] + ) + res = super().write(vals) + if state_change: + for rec in self: + rec._event(f"on_edi_{self._table}_state_change").notify( + rec, state=vals["state"] + ) + return res From 040b1db6e9a9b36dd3bad92e090140adea780df9 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 2 Dec 2024 11:04:47 +0100 Subject: [PATCH 6/7] edi_oca: refactor exchange_generate_send Make it easier to understand how it works and avoid checks on attributes. --- edi_oca/models/edi_backend.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/edi_oca/models/edi_backend.py b/edi_oca/models/edi_backend.py index 6791a1f1d9..4527df3e24 100644 --- a/edi_oca/models/edi_backend.py +++ b/edi_oca/models/edi_backend.py @@ -399,17 +399,27 @@ def _cron_check_output_exchange_sync(self, **kw): backend._check_output_exchange_sync(**kw) def exchange_generate_send(self, recordset, skip_generate=False, skip_send=False): + """Generate and send output files for given records. + + If both are False, the record will be generated and sent right away + with chained jobs. + + If both `skip_generate` and `skip_send` are True, nothing will be done. + :param recordset: edi.exchange.record recordset + :param skip_generate: only send records + :param skip_send: only generate missing output + """ for rec in recordset: - if skip_generate: - job1 = rec - else: + if not skip_generate and not skip_send: job1 = rec.delayable().action_exchange_generate() - if hasattr(job1, "on_done"): - if not skip_send: - # Chain send job. - # Raise prio to max to send the record out as fast as possible. - job1.on_done(rec.delayable(priority=0).action_exchange_send()) + # Chain send job. + # Raise prio to max to send the record out as fast as possible. + job1.on_done(rec.delayable(priority=0).action_exchange_send()) job1.delay() + elif skip_send: + rec.with_delay().action_exchange_generate() + elif not skip_send: + rec.with_delay(priority=0).action_exchange_send() def _check_output_exchange_sync( self, skip_send=False, skip_sent=True, record_ids=None From 2ffaca8229cc917b2310239e6bd28923a13e1856 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 2 Dec 2024 14:31:29 +0100 Subject: [PATCH 7/7] edi_oca: edi_conf avoid clash of label for model fields --- edi_oca/models/edi_configuration.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/edi_oca/models/edi_configuration.py b/edi_oca/models/edi_configuration.py index 0dbc880031..8c2c254a62 100644 --- a/edi_oca/models/edi_configuration.py +++ b/edi_oca/models/edi_configuration.py @@ -50,7 +50,9 @@ class EdiConfiguration(models.Model): string="Model", help="Model the conf applies to. Leave blank to apply for all models", ) - model_name = fields.Char(related="model_id.model", store=True) + model_name = fields.Char( + related="model_id.model", store=True, string="Model tech name" + ) trigger_id = fields.Many2one( string="Trigger", comodel_name="edi.configuration.trigger",