diff --git a/edi_storage_oca/__manifest__.py b/edi_storage_oca/__manifest__.py index 39f47e247..1fac0f943 100644 --- a/edi_storage_oca/__manifest__.py +++ b/edi_storage_oca/__manifest__.py @@ -15,6 +15,7 @@ "depends": ["edi_core_oca", "fs_storage"], "data": [ "data/cron.xml", + "data/edi_configuration.xml", "security/ir_model_access.xml", "views/edi_backend_views.xml", ], diff --git a/edi_storage_oca/data/edi_configuration.xml b/edi_storage_oca/data/edi_configuration.xml new file mode 100644 index 000000000..53956f2eb --- /dev/null +++ b/edi_storage_oca/data/edi_configuration.xml @@ -0,0 +1,29 @@ + + + + + Storage: move input file to done + + + + record.backend_id._storage_on_edi_exchange_done(record) + + + + Storage: move input file to error + + + + record.backend_id._storage_on_edi_exchange_error(record) + + diff --git a/edi_storage_oca/models/edi_backend.py b/edi_storage_oca/models/edi_backend.py index e6767a789..4e3fe51ba 100644 --- a/edi_storage_oca/models/edi_backend.py +++ b/edi_storage_oca/models/edi_backend.py @@ -148,3 +148,53 @@ def _storage_new_exchange_record_vals(self, file_name): "edi_exchange_state": "input_pending", "storage_id": self.storage_id.id, } + + def _storage_on_edi_exchange_done(self, exchange_record): + """ + Move an input file from the pending dir to the done dir. + + Intended to be invoked from a global 'edi.configuration' snippet + bound to the 'on_edi_exchange_done' trigger. + """ + self.ensure_one() + storage = exchange_record.storage_id + if exchange_record.direction != "input" or not storage: + return False + if not self.input_dir_done: + return False + file_name = exchange_record.exchange_filename + pending_dir = exchange_record.type_id._storage_fullpath( + self.input_dir_pending + ).as_posix() + done_dir = exchange_record.type_id._storage_fullpath( + self.input_dir_done + ).as_posix() + error_dir = exchange_record.type_id._storage_fullpath( + self.input_dir_error + ).as_posix() + res = storage._move_file(pending_dir, done_dir, file_name) + if not res and self.input_dir_error: + res = storage._move_file(error_dir, done_dir, file_name) + return res + + def _storage_on_edi_exchange_error(self, exchange_record): + """ + Move an input file from the pending dir to the error dir. + + Intended to be invoked from a global 'edi.configuration' snippet + bound to the 'on_edi_exchange_error' trigger. + """ + self.ensure_one() + storage = exchange_record.storage_id + if exchange_record.direction != "input" or not storage: + return False + if not self.input_dir_error: + return False + file_name = exchange_record.exchange_filename + pending_dir = exchange_record.type_id._storage_fullpath( + self.input_dir_pending + ).as_posix() + error_dir = exchange_record.type_id._storage_fullpath( + self.input_dir_error + ).as_posix() + return storage._move_file(pending_dir, error_dir, file_name) diff --git a/edi_storage_oca/readme/CONFIGURE.md b/edi_storage_oca/readme/CONFIGURE.md new file mode 100644 index 000000000..4050a7424 --- /dev/null +++ b/edi_storage_oca/readme/CONFIGURE.md @@ -0,0 +1,10 @@ +This module has two **inactive** global `edi.configuration` records +that move the input file across the storage directories on +`on_edi_exchange_done` / `on_edi_exchange_error`: + +- *Storage: move input file, pending → done (fallback error → done) +- *Storage: move input file, pending → error + +Before enabling them you **must** set `backend_id` on the record: +otherwise the global match runs against every backend in the database, +including non-storage ones. diff --git a/edi_storage_oca/tests/__init__.py b/edi_storage_oca/tests/__init__.py index 0a73fb8a1..54510bc1a 100644 --- a/edi_storage_oca/tests/__init__.py +++ b/edi_storage_oca/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_edi_backend_storage +from . import test_event_listener from . import test_exchange_type diff --git a/edi_storage_oca/tests/test_event_listener.py b/edi_storage_oca/tests/test_event_listener.py new file mode 100644 index 000000000..2b3fbc400 --- /dev/null +++ b/edi_storage_oca/tests/test_event_listener.py @@ -0,0 +1,88 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import base64 +from unittest import mock + +from odoo_test_helper import FakeModelLoader + +from odoo.addons.edi_core_oca.tests.common import EDIBackendCommonTestCase +from odoo.addons.edi_core_oca.tests.fake_models import EdiTestExecution + +STORAGE_MOVE_FILE_PATH = "odoo.addons.fs_storage.models.fs_storage.FSStorage._move_file" + + +class TestStorageEventListener(EDIBackendCommonTestCase): + @classmethod + def _get_backend(cls): + return cls.env.ref("edi_storage_oca.demo_edi_backend_storage") + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.conf_done = cls.env.ref("edi_storage_oca.edi_conf_storage_move_on_done") + cls.conf_error = cls.env.ref("edi_storage_oca.edi_conf_storage_move_on_error") + (cls.conf_done | cls.conf_error).write( + {"active": True, "backend_id": cls.backend.id} + ) + + vals = { + "model": cls.partner._name, + "res_id": cls.partner.id, + "exchange_file": base64.b64encode(b"1234"), + "storage_id": cls.backend.storage_id.id, + } + cls.record = cls.backend.create_record("test_csv_input", vals) + + def setUp(self): + super().setUp() + self.loader = FakeModelLoader(self.env, self.__module__) + self.loader.backup_registry() + + self.loader.update_registry((EdiTestExecution,)) + fake_model = self.env["ir.model"].search( + [("model", "=", "edi.framework.test.execution")] + ) + self.exchange_type_in.process_model_id = fake_model + self.exchange_type_in.input_validate_model_id = fake_model + + def tearDown(self): + self.loader.restore_registry() + super().tearDown() + + def _patch_move_file(self): + return mock.patch(STORAGE_MOVE_FILE_PATH, autospec=True, return_value=True) + + def _expected_dir(self, raw_dir): + return self.exchange_type_in._storage_fullpath(raw_dir).as_posix() + + def test_01_process_record_success(self): + self.record.write({"edi_exchange_state": "input_received"}) + with self._patch_move_file() as mocked: + self.record.action_exchange_process() + mocked.assert_called_once() + storage, from_dir_str, to_dir_str, filename = mocked.call_args[0] + self.assertEqual(storage, self.backend.storage_id) + self.assertEqual( + from_dir_str, self._expected_dir(self.backend.input_dir_pending) + ) + self.assertEqual(to_dir_str, self._expected_dir(self.backend.input_dir_done)) + self.assertEqual(filename, self.record.exchange_filename) + + def test_02_process_record_with_error(self): + self.record.write({"edi_exchange_state": "input_received"}) + self.record._set_file_content("TEST %d" % self.record.id) + with self._patch_move_file() as mocked: + self.record.with_context( + test_break_process="OOPS! Something went wrong :(" + ).action_exchange_process() + mocked.assert_called_once() + storage, from_dir_str, to_dir_str, filename = mocked.call_args[0] + self.assertEqual(storage, self.backend.storage_id) + self.assertEqual( + from_dir_str, self._expected_dir(self.backend.input_dir_pending) + ) + self.assertEqual(to_dir_str, self._expected_dir(self.backend.input_dir_error)) + self.assertEqual(filename, self.record.exchange_filename)