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..2def86a48e 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,8 @@ "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/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 new file mode 100644 index 0000000000..8623f39ec7 --- /dev/null +++ b/edi_oca/data/edi_configuration.xml @@ -0,0 +1,42 @@ + + + + + + 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 + + record._edi_send_via_edi(conf.type_id) + + diff --git a/edi_oca/models/__init__.py b/edi_oca/models/__init__.py index f40b0abe1e..4c3433a3a8 100644 --- a/edi_oca/models/__init__.py +++ b/edi_oca/models/__init__.py @@ -5,3 +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_backend.py b/edi_oca/models/edi_backend.py index 427c435d61..4527df3e24 100644 --- a/edi_oca/models/edi_backend.py +++ b/edi_oca/models/edi_backend.py @@ -398,6 +398,29 @@ 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): + """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 not skip_generate and not skip_send: + job1 = rec.delayable().action_exchange_generate() + # 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 ): @@ -415,13 +438,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..8c2c254a62 --- /dev/null +++ b/edi_oca/models/edi_configuration.py @@ -0,0 +1,200 @@ +# 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) + 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, string="Model tech name" + ) + trigger_id = fields.Many2one( + string="Trigger", + 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", + ) + 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_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/models/edi_exchange_consumer_mixin.py b/edi_oca/models/edi_exchange_consumer_mixin.py index 773b678bd4..513bb09a89 100644 --- a/edi_oca/models/edi_exchange_consumer_mixin.py +++ b/edi_oca/models/edi_exchange_consumer_mixin.py @@ -293,3 +293,131 @@ 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 + + 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 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..ad19d127c1 100644 --- a/edi_oca/security/ir_model_access.xml +++ b/edi_oca/security/ir_model_access.xml @@ -139,4 +139,40 @@ name="domain_force" >['|',('company_id','=',False),('company_id', 'in', company_ids)] + + access_edi_configuration manager + + + + + + + + + access_edi_configuration user + + + + + + + + + access_edi_configuration_trigger manager + + + + + + + + + access_edi_configuration_trigger 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..449ded4b61 100644 --- a/edi_oca/tests/fake_components.py +++ b/edi_oca/tests/fake_components.py @@ -134,3 +134,23 @@ 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(self, record, fields=None, **kwargs): + trigger = "on_record_write" + 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(self, record, fields=None, **kwargs): + trigger = "on_record_create" + 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 44bd3b73fe..364c84faef 100644 --- a/edi_oca/tests/fake_models.py +++ b/edi_oca/tests/fake_models.py @@ -11,6 +11,14 @@ 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 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") diff --git a/edi_oca/tests/test_edi_configuration.py b/edi_oca/tests/test_edi_configuration.py new file mode 100644 index 0000000000..d1264979bf --- /dev/null +++ b/edi_oca/tests/test_edi_configuration.py @@ -0,0 +1,161 @@ +# 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, + "backend_id": cls.backend.id, + "type_id": cls.exchange_type_out.id, + "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)", + } + ) + cls.write_config = cls.edi_configuration.create( + { + "name": "Write Config 1", + "active": True, + "backend_id": cls.backend.id, + "type_id": cls.exchange_type_out.id, + "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)", + } + ) + + @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_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 new file mode 100644 index 0000000000..dd5de3fe9e --- /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" /> +