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..a6d5b40fc 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 = utils.move_file(storage, pending_dir, done_dir, file_name) + if not res and self.input_dir_error: + res = utils.move_file(storage, 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 utils.move_file(storage, 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..af9be8741 --- /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.edi_storage_oca.utils.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) diff --git a/edi_storage_oca/utils.py b/edi_storage_oca/utils.py index 0b93e5da0..7ce442dca 100644 --- a/edi_storage_oca/utils.py +++ b/edi_storage_oca/utils.py @@ -2,8 +2,10 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) import base64 +import functools import os import re +from pathlib import PurePath def add_file(storage, path, filedata, binary=False): @@ -58,3 +60,13 @@ def move_files(storage, files, destination_path, **kw): storage.fs.sep.join([destination_path, os.path.basename(file_path)]), **kw, ) + + +# TODO: drop this helper once https://github.com/OCA/storage/pull/606 is merged. +def move_file(storage, from_dir_str, to_dir_str, filename): + src = (PurePath(from_dir_str) / filename).as_posix() + if not storage.fs.exists(src): + return False + dst = (PurePath(to_dir_str) / filename).as_posix() + storage.env.cr.postcommit.add(functools.partial(storage.fs.move, src, dst)) + return True