diff --git a/edi_oca/README.rst b/edi_oca/README.rst index acde589e405..488fe835632 100644 --- a/edi_oca/README.rst +++ b/edi_oca/README.rst @@ -166,6 +166,7 @@ Contributors * Simone Orsi * Enric Tobella +* Thien Vo Maintainers ~~~~~~~~~~~ diff --git a/edi_oca/__manifest__.py b/edi_oca/__manifest__.py index 6bdcbe77b1a..64c4b6a17bc 100644 --- a/edi_oca/__manifest__.py +++ b/edi_oca/__manifest__.py @@ -29,6 +29,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", @@ -36,6 +37,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/menuitems.xml", "templates/exchange_chatter_msg.xml", "templates/exchange_mixin_buttons.xml", diff --git a/edi_oca/data/edi_configuration.xml b/edi_oca/data/edi_configuration.xml new file mode 100644 index 00000000000..e9784a132c3 --- /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 f40b0abe1e5..c5223ae7f6f 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 504bcd5b72d..dded57286fb 100644 --- a/edi_oca/models/edi_backend.py +++ b/edi_oca/models/edi_backend.py @@ -377,6 +377,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 ): @@ -396,13 +409,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 00000000000..d6b693b8434 --- /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 f5243e7128f..9a758d69a27 100644 --- a/edi_oca/models/edi_exchange_record.py +++ b/edi_oca/models/edi_exchange_record.py @@ -308,6 +308,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 4945a3dc401..033aac71242 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 83970fb3c5a..5ffa4e7a539 100644 --- a/edi_oca/security/ir_model_access.xml +++ b/edi_oca/security/ir_model_access.xml @@ -113,4 +113,22 @@ [(1, '=', 1)] + + 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 f263f4a038f..f692c9664e5 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 { @@ -516,14 +515,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 30692700a2a..1373da895b3 100644 --- a/edi_oca/tests/__init__.py +++ b/edi_oca/tests/__init__.py @@ -13,3 +13,4 @@ from . import test_security from . import test_quick_exec from . import test_exchange_type_deprecated_fields +from . import test_edi_configuration diff --git a/edi_oca/tests/fake_components.py b/edi_oca/tests/fake_components.py index f764c5e7567..1602547f529 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 44bd3b73fe8..cb878903678 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 00000000000..36cfd386d2b --- /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 00000000000..fed81de2137 --- /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 3842809d315..9a2b1fd8f19 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" /> +