From b2742eabbf638e04ac6205e6bdbe9953c67381a8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 3 Jun 2026 10:32:41 +0200 Subject: [PATCH 01/16] [FIX] tests: pin aiohttp<3.12 for S3 VCR tests --- test-requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test-requirements.txt b/test-requirements.txt index ddd6ddda03..02717a0087 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -4,3 +4,6 @@ vcrpy-unittest s3fs>=2025.3.0 pyOpenSSL<24 cryptography<43 +# S3 test suite importing vcr’s aiohttp stubs +# require aiohttp version with AsyncStreamReaderMixin. +aiohttp<3.12 From ccdd48b5224c2f499e82f6c3810a0931400b1dbb Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 5 Jun 2026 16:31:07 +0200 Subject: [PATCH 02/16] [FIX] storage_backend_s3: reduce test log noise --- storage_backend_s3/tests/test_amazon_s3.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/storage_backend_s3/tests/test_amazon_s3.py b/storage_backend_s3/tests/test_amazon_s3.py index fdbc8a2470..d85d5880e5 100644 --- a/storage_backend_s3/tests/test_amazon_s3.py +++ b/storage_backend_s3/tests/test_amazon_s3.py @@ -15,6 +15,9 @@ _logger = logging.getLogger(__name__) +# avoid bloating logs with vcr debug info, which can be very large for S3 tests +logging.getLogger("vcr.cassette").setLevel(logging.WARNING) + class AmazonS3Case(VCRMixin, CommonCase, BackendStorageTestMixin): def _get_vcr_kwargs(self, **kwargs): From b442c6b1ae4f3fb6802c9893a6a48253910dfd97 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 12 May 2026 18:29:46 +0200 Subject: [PATCH 03/16] [ADD] storage_file: swap backend wizard Add a "Swap Storage Backend" wizard in `storage_file` to move one or more `storage.file` records between backends. Triggered via an `ir.actions.server` bound to `storage.file` list/form views, so it works on multi-selection from the Action menu. The wizard is pre-filled with the current selection's backend as source and refuses any selection spanning multiple backends. The destination domain excludes the source. On apply, `storage.file._swap_backend(new_backend)`: * downloads the binary from the old backend, * uploads to the new one (recomputing `relative_path` from the destination `filename_strategy`), * updates `backend_id` and `relative_path`, * schedules deletion of the old physical file via `cr.postcommit`, so the original is removed only if the transaction commits. On rollback the source file is preserved. Records already on the destination are skipped. The wizard exposes a `_resolve_file_ids(active_model, active_ids)` hook so submodules can map their own records to the underlying `storage.file` ids. `storage_image` and `storage_media` will register server actions on their models that reuse the base wizard. --- storage_file/__manifest__.py | 1 + storage_file/models/storage_file.py | 67 ++++++++ storage_file/readme/CONTRIBUTORS.md | 1 + storage_file/security/ir.model.access.csv | 1 + storage_file/tests/__init__.py | 1 + storage_file/tests/test_swap_backend.py | 192 ++++++++++++++++++++++ storage_file/wizards/__init__.py | 1 + storage_file/wizards/swap_backend.py | 80 +++++++++ storage_file/wizards/swap_backend.xml | 58 +++++++ 9 files changed, 402 insertions(+) create mode 100644 storage_file/tests/test_swap_backend.py create mode 100644 storage_file/wizards/swap_backend.py create mode 100644 storage_file/wizards/swap_backend.xml diff --git a/storage_file/__manifest__.py b/storage_file/__manifest__.py index 77bee313bf..fbeb447a37 100644 --- a/storage_file/__manifest__.py +++ b/storage_file/__manifest__.py @@ -22,5 +22,6 @@ "security/storage_file.xml", "data/ir_cron.xml", "data/storage_backend.xml", + "wizards/swap_backend.xml", ], } diff --git a/storage_file/models/storage_file.py b/storage_file/models/storage_file.py index 2617e5fac6..ff43d9efc1 100644 --- a/storage_file/models/storage_file.py +++ b/storage_file/models/storage_file.py @@ -220,6 +220,73 @@ def _clean_storage_file(self, batch_size=1000): done=done, remaining=0 if done <= batch_size else len(ids) - done ) + def _swap_backend(self, new_backend): + """Swap files to ``new_backend``. + + For each record: + + - read binary data from the current backend storage, + - upload it to the new backend (re-computing relative_path using the + new backend filename strategy), + - update ``backend_id`` and ``relative_path`` on the record, + - schedule deletion of the old file from the old backend via a + post-commit hook, so the original file is only removed if the + rest of the transaction (upload + DB update) actually committed. + + Files already on ``new_backend`` are skipped. + """ + if not new_backend: + raise UserError(self.env._("A destination storage is required.")) + new_backend = new_backend.sudo() + if not new_backend.filename_strategy: + raise UserError( + self.env._( + "The filename strategy is empty for the backend %s.\n" + "Please configure it." + ) + % new_backend.name + ) + for record in self.sudo(): + if record.backend_id == new_backend: + continue + old_backend = record.backend_id + old_relative_path = record.relative_path + if not old_relative_path: + # Nothing physical to swap, just update the backend. + record.sudo().backend_id = new_backend + continue + bin_data = old_backend.get(old_relative_path, binary=True) + # Switch backend first so that ``_build_relative_path`` uses the + # destination backend filename strategy. + record.backend_id = new_backend + new_relative_path = record._build_relative_path(record.checksum) + new_backend.add( + new_relative_path, + bin_data, + mimetype=record.mimetype, + binary=True, + ) + record.relative_path = new_relative_path + self._register_old_file_deletion(old_backend, old_relative_path) + + @api.model + def _register_old_file_deletion(self, old_backend, old_relative_path): + """Schedule deletion of an old physical file after commit.""" + backend = old_backend.sudo() + + def _delete_old_file(): + try: + backend.delete(old_relative_path) + except Exception as exc: + _logger.warning( + "Failed to delete %s from backend %s after swap: %s", + old_relative_path, + backend.name, + exc, + ) + + self.env.cr.postcommit.add(_delete_old_file) + @api.model def get_from_slug_name_with_id(self, slug_name_with_id): """ diff --git a/storage_file/readme/CONTRIBUTORS.md b/storage_file/readme/CONTRIBUTORS.md index c74afd70a8..bd6bee42d4 100644 --- a/storage_file/readme/CONTRIBUTORS.md +++ b/storage_file/readme/CONTRIBUTORS.md @@ -1,3 +1,4 @@ - Sebastien Beau \<\> - Raphaël Reverdy \<\> - Vo Hong Thien \<\> +- Simone Orsi \<\> diff --git a/storage_file/security/ir.model.access.csv b/storage_file/security/ir.model.access.csv index 55e7050bee..4532fee45a 100644 --- a/storage_file/security/ir.model.access.csv +++ b/storage_file/security/ir.model.access.csv @@ -2,3 +2,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_storage_file_edit,storage_file edit,model_storage_file,base.group_system,1,1,1,1 access_storage_file_read_public,storage_file public read,model_storage_file,base.group_user,1,0,0,0 access_storage_file_replace,storage_file_replace public,model_storage_file_replace,base.group_user,1,1,1,1 +access_storage_file_swap_backend,storage_file_swap_backend admin,model_storage_file_swap_backend,base.group_system,1,1,1,1 diff --git a/storage_file/tests/__init__.py b/storage_file/tests/__init__.py index f2d7ae7ade..cdf4f78fa1 100644 --- a/storage_file/tests/__init__.py +++ b/storage_file/tests/__init__.py @@ -1 +1,2 @@ from . import test_storage_file +from . import test_swap_backend diff --git a/storage_file/tests/test_swap_backend.py b/storage_file/tests/test_swap_backend.py new file mode 100644 index 0000000000..f8bea68f38 --- /dev/null +++ b/storage_file/tests/test_swap_backend.py @@ -0,0 +1,192 @@ +# Copyright 2026 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import base64 +from unittest import mock + +from odoo.exceptions import UserError +from odoo.tests import Form + +from odoo.addons.component.tests.common import TransactionComponentCase + + +class TestSwapBackend(TransactionComponentCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.backend_a = cls.env.ref("storage_backend.default_storage_backend") + cls.backend_b = cls.backend_a.copy( + { + "name": "Second Backend", + "directory_path": "backend_b", + } + ) + cls.backend_b.filename_strategy = "name_with_id" + + def _create_storage_file(self, name="my_file.txt", data=b"hello", backend=None): + return self.env["storage.file"].create( + { + "name": name, + "backend_id": (backend or self.backend_a).id, + "data": base64.b64encode(data), + } + ) + + # -- model-level swap ------------------------------------------------- + + def test_swap_uploads_to_new_backend_and_updates_record(self): + stfile = self._create_storage_file(data=b"payload") + old_relative_path = stfile.relative_path + + stfile._swap_backend(self.backend_b) + + self.assertEqual(stfile.backend_id, self.backend_b) + self.assertEqual(stfile.relative_path, f"my_file-{stfile.id}.txt") + # Data is readable from the new backend. + self.assertEqual(base64.b64decode(stfile.data), b"payload") + # Old file still physically present until postcommit runs. + self.assertEqual( + self.backend_a.sudo().get(old_relative_path, binary=True), b"payload" + ) + + def test_swap_deletes_old_file_only_on_postcommit(self): + stfile = self._create_storage_file(data=b"payload") + old_relative_path = stfile.relative_path + stfile._swap_backend(self.backend_b) + # Run postcommit hooks manually (TestCursor clears them on commit). + self.env.cr.postcommit.run() + # Old physical file is gone. + with self.assertRaises(FileNotFoundError): + self.backend_a.sudo().get(old_relative_path, binary=True) + + def test_swap_skips_records_already_on_destination(self): + stfile = self._create_storage_file(backend=self.backend_b) + with mock.patch.object( + type(self.env["storage.backend"]), "delete" + ) as mocked_delete: + stfile._swap_backend(self.backend_b) + self.env.cr.postcommit.run() + mocked_delete.assert_not_called() + self.assertEqual(stfile.backend_id, self.backend_b) + + def test_swap_requires_destination(self): + stfile = self._create_storage_file() + with self.assertRaisesRegex(UserError, "A destination storage is required"): + stfile._swap_backend(self.env["storage.backend"]) + + def test_swap_requires_destination_filename_strategy(self): + self.backend_b.filename_strategy = False + stfile = self._create_storage_file() + with self.assertRaisesRegex(UserError, "The filename strategy is empty"): + stfile._swap_backend(self.backend_b) + + def test_swap_failure_does_not_delete_old_file(self): + """If something blows up before commit, the postcommit hook never runs, + so the original file is preserved.""" + stfile = self._create_storage_file(data=b"payload") + old_relative_path = stfile.relative_path + with mock.patch.object( + type(self.env["storage.backend"]), + "add", + side_effect=RuntimeError("boom"), + ): + with self.assertRaisesRegex(RuntimeError, "boom"): + stfile._swap_backend(self.backend_b) + # Simulate transaction rollback: do NOT run postcommit (the hook is + # registered, but on rollback Odoo clears it). + self.env.cr.postcommit.clear() + # Old file still physically present. + self.assertEqual( + self.backend_a.sudo().get(old_relative_path, binary=True), b"payload" + ) + + def test_swap_swallows_old_backend_delete_error(self): + stfile = self._create_storage_file(data=b"payload") + stfile._swap_backend(self.backend_b) + with mock.patch.object( + type(self.env["storage.backend"]), + "delete", + side_effect=RuntimeError("boom"), + ): + # Should not raise, just log a warning. + with self.assertLogs( + "odoo.addons.storage_file.models.storage_file", level="WARNING" + ) as log_cm: + self.env.cr.postcommit.run() + self.assertTrue( + any("Failed to delete" in msg and "boom" in msg for msg in log_cm.output), + log_cm.output, + ) + + # -- wizard ---------------------------------------------------------------- + + def test_wizard_default_get_single_backend(self): + stfile1 = self._create_storage_file(name="f1.txt") + stfile2 = self._create_storage_file(name="f2.txt") + wiz = ( + self.env["storage.file.swap.backend"] + .with_context( + active_model="storage.file", + active_ids=[stfile1.id, stfile2.id], + ) + .create({}) + ) + self.assertEqual(wiz.source_backend_id, self.backend_a) + self.assertEqual(wiz.file_ids, stfile1 + stfile2) + + def test_wizard_default_get_rejects_mixed_backends(self): + stfile1 = self._create_storage_file(name="f1.txt") + stfile2 = self._create_storage_file(name="f2.txt", backend=self.backend_b) + with self.assertRaisesRegex( + UserError, + "All selected records must belong to the same source storage backend", + ): + self.env["storage.file.swap.backend"].with_context( + active_model="storage.file", + active_ids=[stfile1.id, stfile2.id], + ).create({}) + + def test_wizard_apply_swaps_files(self): + stfile = self._create_storage_file(data=b"payload") + wiz = ( + self.env["storage.file.swap.backend"] + .with_context( + active_model="storage.file", + active_ids=stfile.ids, + ) + .create({"dest_backend_id": self.backend_b.id}) + ) + wiz.action_apply() + self.env.cr.postcommit.run() + self.assertEqual(stfile.backend_id, self.backend_b) + + def test_wizard_apply_rejects_same_backend(self): + stfile = self._create_storage_file() + wiz = ( + self.env["storage.file.swap.backend"] + .with_context( + active_model="storage.file", + active_ids=stfile.ids, + ) + .create({}) + ) + wiz.dest_backend_id = wiz.source_backend_id + with self.assertRaisesRegex( + UserError, "Destination storage must differ from source" + ): + wiz.action_apply() + + def test_wizard_form_loads_with_source_backend(self): + """The form view loads and pre-fills source_backend_id.""" + stfile = self._create_storage_file() + view = "storage_file.storage_file_swap_backend_view_form" + with Form( + self.env["storage.file.swap.backend"].with_context( + active_model="storage.file", + active_ids=stfile.ids, + ), + view=view, + ) as wiz_form: + self.assertEqual(wiz_form.source_backend_id, self.backend_a) + wiz_form.dest_backend_id = self.backend_b diff --git a/storage_file/wizards/__init__.py b/storage_file/wizards/__init__.py index 3993be0f08..aea293da11 100644 --- a/storage_file/wizards/__init__.py +++ b/storage_file/wizards/__init__.py @@ -1 +1,2 @@ +from . import swap_backend from . import replace_file diff --git a/storage_file/wizards/swap_backend.py b/storage_file/wizards/swap_backend.py new file mode 100644 index 0000000000..0fa5ebbbd2 --- /dev/null +++ b/storage_file/wizards/swap_backend.py @@ -0,0 +1,80 @@ +# Copyright 2026 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import logging + +from odoo import api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class StorageFileSwapBackend(models.TransientModel): + _name = "storage.file.swap.backend" + _description = "Swap storage files between backends" + + source_backend_id = fields.Many2one( + "storage.backend", + string="Source Storage", + readonly=True, + ) + dest_backend_id = fields.Many2one( + "storage.backend", + string="Destination Storage", + domain="[('id', '!=', source_backend_id)]", + ) + file_ids = fields.Many2many( + "storage.file", + string="Files", + domain="[('backend_id', '=', source_backend_id)]", + ) + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + active_model = self.env.context.get("active_model") + active_ids = self.env.context.get("active_ids") or [] + file_ids = self._resolve_file_ids(active_model, active_ids) + if not file_ids: + return res + files = self.env["storage.file"].browse(file_ids) + backends = files.mapped("backend_id") + if len(backends) > 1: + raise UserError( + self.env._( + "All selected records must belong to the same source " + "storage backend. Found: %s" + ) + % ", ".join(backends.mapped("name")) + ) + res["source_backend_id"] = backends.id + res["file_ids"] = [(6, 0, files.ids)] + return res + + @api.model + def _resolve_file_ids(self, active_model, active_ids): + """Map selected records to underlying storage.file ids. + + Override in modules adding new models inheriting storage.file via + ``_inherits`` (e.g. storage.image, storage.media). + """ + if not active_ids: + return [] + if active_model == "storage.file": + return list(active_ids) + model = self.env.get(active_model) + if model is not None and "file_id" in model._fields: + return model.browse(active_ids).mapped("file_id").ids + return [] + + def action_apply(self): + self.ensure_one() + if not self.file_ids: + raise UserError(self.env._("Please select at least one file.")) + if not self.dest_backend_id: + raise UserError(self.env._("Please select a destination storage.")) + if self.dest_backend_id == self.source_backend_id: + raise UserError(self.env._("Destination storage must differ from source.")) + self.file_ids._swap_backend(self.dest_backend_id) + return {"type": "ir.actions.act_window_close"} diff --git a/storage_file/wizards/swap_backend.xml b/storage_file/wizards/swap_backend.xml new file mode 100644 index 0000000000..5aac2f5e81 --- /dev/null +++ b/storage_file/wizards/swap_backend.xml @@ -0,0 +1,58 @@ + + + + + storage.file.swap.backend.form + storage.file.swap.backend + +
+ + + + + + + + + + + + + + + +
+
+
+
+
+ + + Swap Storage Backend + ir.actions.act_window + storage.file.swap.backend + form + new + + + + + Swap Storage Backend + + + list,form + code + action = env["ir.actions.act_window"]._for_xml_id("storage_file.storage_file_swap_backend_action") + +
From 7849e93272d1062f1d2888f76b76ae1a384330d8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 28 May 2026 14:37:42 +0200 Subject: [PATCH 04/16] [IMP] storage_file: simplify backend swap and make it hookable * drop the postcommit part * make it hookable so that for big batches of files we can rely on queue_job --- storage_file/models/storage_file.py | 80 +++++++++++++------------ storage_file/tests/test_swap_backend.py | 48 +++++++-------- storage_file/wizards/swap_backend.py | 9 ++- 3 files changed, 70 insertions(+), 67 deletions(-) diff --git a/storage_file/models/storage_file.py b/storage_file/models/storage_file.py index ff43d9efc1..d8a2949405 100644 --- a/storage_file/models/storage_file.py +++ b/storage_file/models/storage_file.py @@ -229,11 +229,12 @@ def _swap_backend(self, new_backend): - upload it to the new backend (re-computing relative_path using the new backend filename strategy), - update ``backend_id`` and ``relative_path`` on the record, - - schedule deletion of the old file from the old backend via a - post-commit hook, so the original file is only removed if the - rest of the transaction (upload + DB update) actually committed. + - delete the old file from the previous backend. Files already on ``new_backend`` are skipped. + + :return: dict with ``moved`` (list of names) and ``failed`` (list of + error descriptions). """ if not new_backend: raise UserError(self.env._("A destination storage is required.")) @@ -242,50 +243,55 @@ def _swap_backend(self, new_backend): raise UserError( self.env._( "The filename strategy is empty for the backend %s.\n" - "Please configure it." + "Please configure it.", + new_backend.name, ) - % new_backend.name ) + moved = [] + failed = [] for record in self.sudo(): + if not record.exists(): + failed.append(f"ID {record.id}: record no longer exists") + continue if record.backend_id == new_backend: continue old_backend = record.backend_id old_relative_path = record.relative_path - if not old_relative_path: - # Nothing physical to swap, just update the backend. - record.sudo().backend_id = new_backend - continue - bin_data = old_backend.get(old_relative_path, binary=True) - # Switch backend first so that ``_build_relative_path`` uses the - # destination backend filename strategy. - record.backend_id = new_backend - new_relative_path = record._build_relative_path(record.checksum) - new_backend.add( - new_relative_path, - bin_data, - mimetype=record.mimetype, - binary=True, - ) - record.relative_path = new_relative_path - self._register_old_file_deletion(old_backend, old_relative_path) - - @api.model - def _register_old_file_deletion(self, old_backend, old_relative_path): - """Schedule deletion of an old physical file after commit.""" - backend = old_backend.sudo() - - def _delete_old_file(): try: - backend.delete(old_relative_path) + if not old_relative_path: + record.backend_id = new_backend + moved.append(f"{record.name} (ID {record.id})") + continue + bin_data = old_backend.get(old_relative_path, binary=True) + record.backend_id = new_backend + new_relative_path = record._build_relative_path(record.checksum) + new_backend.add( + new_relative_path, + bin_data, + mimetype=record.mimetype, + binary=True, + ) + record.relative_path = new_relative_path + try: + old_backend.delete(old_relative_path) + except Exception as exc: + _logger.warning( + "Swapped %s but failed to delete old file %s from %s: %s", + record.name, + old_relative_path, + old_backend.name, + exc, + ) + moved.append(f"{record.name} (ID {record.id})") except Exception as exc: - _logger.warning( - "Failed to delete %s from backend %s after swap: %s", - old_relative_path, - backend.name, - exc, + failed.append(f"{record.name} (ID {record.id}): {exc}") + _logger.exception( + "Failed to swap file %s (ID %d) to backend %s", + record.name, + record.id, + new_backend.name, ) - - self.env.cr.postcommit.add(_delete_old_file) + return {"moved": moved, "failed": failed} @api.model def get_from_slug_name_with_id(self, slug_name_with_id): diff --git a/storage_file/tests/test_swap_backend.py b/storage_file/tests/test_swap_backend.py index f8bea68f38..cc82db284a 100644 --- a/storage_file/tests/test_swap_backend.py +++ b/storage_file/tests/test_swap_backend.py @@ -37,26 +37,18 @@ def _create_storage_file(self, name="my_file.txt", data=b"hello", backend=None): def test_swap_uploads_to_new_backend_and_updates_record(self): stfile = self._create_storage_file(data=b"payload") - old_relative_path = stfile.relative_path - stfile._swap_backend(self.backend_b) + result = stfile._swap_backend(self.backend_b) self.assertEqual(stfile.backend_id, self.backend_b) self.assertEqual(stfile.relative_path, f"my_file-{stfile.id}.txt") - # Data is readable from the new backend. self.assertEqual(base64.b64decode(stfile.data), b"payload") - # Old file still physically present until postcommit runs. - self.assertEqual( - self.backend_a.sudo().get(old_relative_path, binary=True), b"payload" - ) + self.assertIn(stfile.name, result["moved"][0]) - def test_swap_deletes_old_file_only_on_postcommit(self): + def test_swap_deletes_old_file(self): stfile = self._create_storage_file(data=b"payload") old_relative_path = stfile.relative_path stfile._swap_backend(self.backend_b) - # Run postcommit hooks manually (TestCursor clears them on commit). - self.env.cr.postcommit.run() - # Old physical file is gone. with self.assertRaises(FileNotFoundError): self.backend_a.sudo().get(old_relative_path, binary=True) @@ -65,10 +57,11 @@ def test_swap_skips_records_already_on_destination(self): with mock.patch.object( type(self.env["storage.backend"]), "delete" ) as mocked_delete: - stfile._swap_backend(self.backend_b) - self.env.cr.postcommit.run() + result = stfile._swap_backend(self.backend_b) mocked_delete.assert_not_called() self.assertEqual(stfile.backend_id, self.backend_b) + self.assertEqual(result["moved"], []) + self.assertEqual(result["failed"], []) def test_swap_requires_destination(self): stfile = self._create_storage_file() @@ -81,9 +74,8 @@ def test_swap_requires_destination_filename_strategy(self): with self.assertRaisesRegex(UserError, "The filename strategy is empty"): stfile._swap_backend(self.backend_b) - def test_swap_failure_does_not_delete_old_file(self): - """If something blows up before commit, the postcommit hook never runs, - so the original file is preserved.""" + def test_swap_failure_reports_in_failed(self): + """Upload failure is caught and reported in the failed list.""" stfile = self._create_storage_file(data=b"payload") old_relative_path = stfile.relative_path with mock.patch.object( @@ -91,11 +83,15 @@ def test_swap_failure_does_not_delete_old_file(self): "add", side_effect=RuntimeError("boom"), ): - with self.assertRaisesRegex(RuntimeError, "boom"): - stfile._swap_backend(self.backend_b) - # Simulate transaction rollback: do NOT run postcommit (the hook is - # registered, but on rollback Odoo clears it). - self.env.cr.postcommit.clear() + with self.assertLogs( + "odoo.addons.storage_file.models.storage_file", level="ERROR" + ) as log_cm: + result = stfile._swap_backend(self.backend_b) + self.assertEqual(len(result["failed"]), 1) + self.assertIn("boom", result["failed"][0]) + self.assertTrue( + any("Failed to swap file" in msg and "boom" in msg for msg in log_cm.output) + ) # Old file still physically present. self.assertEqual( self.backend_a.sudo().get(old_relative_path, binary=True), b"payload" @@ -103,21 +99,20 @@ def test_swap_failure_does_not_delete_old_file(self): def test_swap_swallows_old_backend_delete_error(self): stfile = self._create_storage_file(data=b"payload") - stfile._swap_backend(self.backend_b) with mock.patch.object( type(self.env["storage.backend"]), "delete", side_effect=RuntimeError("boom"), ): - # Should not raise, just log a warning. with self.assertLogs( "odoo.addons.storage_file.models.storage_file", level="WARNING" ) as log_cm: - self.env.cr.postcommit.run() + result = stfile._swap_backend(self.backend_b) self.assertTrue( - any("Failed to delete" in msg and "boom" in msg for msg in log_cm.output), - log_cm.output, + any("failed to delete" in msg and "boom" in msg for msg in log_cm.output) ) + # File still counts as moved + self.assertEqual(len(result["moved"]), 1) # -- wizard ---------------------------------------------------------------- @@ -158,7 +153,6 @@ def test_wizard_apply_swaps_files(self): .create({"dest_backend_id": self.backend_b.id}) ) wiz.action_apply() - self.env.cr.postcommit.run() self.assertEqual(stfile.backend_id, self.backend_b) def test_wizard_apply_rejects_same_backend(self): diff --git a/storage_file/wizards/swap_backend.py b/storage_file/wizards/swap_backend.py index 0fa5ebbbd2..6cb36245c8 100644 --- a/storage_file/wizards/swap_backend.py +++ b/storage_file/wizards/swap_backend.py @@ -44,9 +44,9 @@ def default_get(self, fields_list): raise UserError( self.env._( "All selected records must belong to the same source " - "storage backend. Found: %s" + "storage backend. Found: %s", + ", ".join(backends.mapped("name")), ) - % ", ".join(backends.mapped("name")) ) res["source_backend_id"] = backends.id res["file_ids"] = [(6, 0, files.ids)] @@ -76,5 +76,8 @@ def action_apply(self): raise UserError(self.env._("Please select a destination storage.")) if self.dest_backend_id == self.source_backend_id: raise UserError(self.env._("Destination storage must differ from source.")) - self.file_ids._swap_backend(self.dest_backend_id) + self._action_apply() return {"type": "ir.actions.act_window_close"} + + def _action_apply(self): + self.file_ids._swap_backend(self.dest_backend_id) From f0147a91df8236f12be0a4302b09c3bc9eaf2f76 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 28 May 2026 14:48:25 +0200 Subject: [PATCH 05/16] [ADD] storage_file_swap_backend_queue Allow delegation of big batch of files to queue jobs. [IMP] storage_file_swap_backend_queue: add flag to turn on/off --- storage_file_swap_backend_queue/README.rst | 1 + storage_file_swap_backend_queue/__init__.py | 2 + .../__manifest__.py | 21 ++ .../data/queue_job_data.xml | 11 ++ .../models/__init__.py | 2 + .../models/storage_backend.py | 17 ++ .../models/storage_file.py | 39 ++++ .../pyproject.toml | 3 + .../readme/CONFIGURE.md | 11 ++ .../readme/DESCRIPTION.md | 7 + .../tests/__init__.py | 1 + .../tests/test_swap_backend_queue.py | 183 ++++++++++++++++++ .../views/storage_backend_view.xml | 15 ++ .../views/swap_backend_view.xml | 18 ++ .../wizards/__init__.py | 1 + .../wizards/swap_backend.py | 30 +++ 16 files changed, 362 insertions(+) create mode 100644 storage_file_swap_backend_queue/README.rst create mode 100644 storage_file_swap_backend_queue/__init__.py create mode 100644 storage_file_swap_backend_queue/__manifest__.py create mode 100644 storage_file_swap_backend_queue/data/queue_job_data.xml create mode 100644 storage_file_swap_backend_queue/models/__init__.py create mode 100644 storage_file_swap_backend_queue/models/storage_backend.py create mode 100644 storage_file_swap_backend_queue/models/storage_file.py create mode 100644 storage_file_swap_backend_queue/pyproject.toml create mode 100644 storage_file_swap_backend_queue/readme/CONFIGURE.md create mode 100644 storage_file_swap_backend_queue/readme/DESCRIPTION.md create mode 100644 storage_file_swap_backend_queue/tests/__init__.py create mode 100644 storage_file_swap_backend_queue/tests/test_swap_backend_queue.py create mode 100644 storage_file_swap_backend_queue/views/storage_backend_view.xml create mode 100644 storage_file_swap_backend_queue/views/swap_backend_view.xml create mode 100644 storage_file_swap_backend_queue/wizards/__init__.py create mode 100644 storage_file_swap_backend_queue/wizards/swap_backend.py diff --git a/storage_file_swap_backend_queue/README.rst b/storage_file_swap_backend_queue/README.rst new file mode 100644 index 0000000000..1333ed77b7 --- /dev/null +++ b/storage_file_swap_backend_queue/README.rst @@ -0,0 +1 @@ +TODO diff --git a/storage_file_swap_backend_queue/__init__.py b/storage_file_swap_backend_queue/__init__.py new file mode 100644 index 0000000000..aee8895e7a --- /dev/null +++ b/storage_file_swap_backend_queue/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/storage_file_swap_backend_queue/__manifest__.py b/storage_file_swap_backend_queue/__manifest__.py new file mode 100644 index 0000000000..c7ef6b09ad --- /dev/null +++ b/storage_file_swap_backend_queue/__manifest__.py @@ -0,0 +1,21 @@ +{ + "name": "Storage File Swap Backend Queue", + "summary": "Delegate storage file backend swap to queue jobs", + "version": "18.0.1.0.0", + "category": "Storage", + "website": "https://github.com/OCA/storage", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["simahawk"], + "license": "LGPL-3", + "development_status": "Beta", + "application": False, + "installable": True, + "depends": ["storage_file", "queue_job"], + "external_dependencies": {"python": []}, + "data": [ + "data/queue_job_data.xml", + "views/storage_backend_view.xml", + "views/swap_backend_view.xml", + ], + "demo": [], +} diff --git a/storage_file_swap_backend_queue/data/queue_job_data.xml b/storage_file_swap_backend_queue/data/queue_job_data.xml new file mode 100644 index 0000000000..650d7611bc --- /dev/null +++ b/storage_file_swap_backend_queue/data/queue_job_data.xml @@ -0,0 +1,11 @@ + + + storage_file_swap + + + + + _swap_backend_job + + + diff --git a/storage_file_swap_backend_queue/models/__init__.py b/storage_file_swap_backend_queue/models/__init__.py new file mode 100644 index 0000000000..4c9dca34ab --- /dev/null +++ b/storage_file_swap_backend_queue/models/__init__.py @@ -0,0 +1,2 @@ +from . import storage_backend +from . import storage_file diff --git a/storage_file_swap_backend_queue/models/storage_backend.py b/storage_file_swap_backend_queue/models/storage_backend.py new file mode 100644 index 0000000000..05e32509e2 --- /dev/null +++ b/storage_file_swap_backend_queue/models/storage_backend.py @@ -0,0 +1,17 @@ +# Copyright 2026 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import fields, models + + +class StorageBackend(models.Model): + _inherit = "storage.backend" + + swap_backend_use_queue = fields.Boolean( + string="Use Queue for Backend Swap", + default=False, + help="When enabled, swapping files to/from this backend " + "will be dispatched as asynchronous queue jobs instead of " + "running synchronously.", + ) diff --git a/storage_file_swap_backend_queue/models/storage_file.py b/storage_file_swap_backend_queue/models/storage_file.py new file mode 100644 index 0000000000..5459d708a5 --- /dev/null +++ b/storage_file_swap_backend_queue/models/storage_file.py @@ -0,0 +1,39 @@ +# Copyright 2026 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import models + + +class StorageFile(models.Model): + _inherit = "storage.file" + + def _swap_backend_job(self, dest_backend_id): + """Job method: swap files to the given backend. + + :return: text summary for the job result UI. + """ + dest_backend = self.env["storage.backend"].browse(dest_backend_id) + if not dest_backend.exists(): + return self.env._( + "Destination backend id=%(backend_id)d no longer exists.", + backend_id=dest_backend_id, + ) + # Filter out records that no longer exist + existing = self.exists() + result = existing._swap_backend(dest_backend) + lines = [] + moved = result.get("moved", []) + failed = result.get("failed", []) + missing = self - existing + if missing: + failed.extend(f"ID {r.id}: record no longer exists" for r in missing) + if moved: + lines.append(f"Moved to {dest_backend.name} ({len(moved)}):") + lines.extend(f" - {m}" for m in moved) + if failed: + lines.append(f"Failed ({len(failed)}):") + lines.extend(f" - {f}" for f in failed) + if not lines: + lines.append("Nothing to swap.") + return "\n".join(lines) diff --git a/storage_file_swap_backend_queue/pyproject.toml b/storage_file_swap_backend_queue/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/storage_file_swap_backend_queue/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/storage_file_swap_backend_queue/readme/CONFIGURE.md b/storage_file_swap_backend_queue/readme/CONFIGURE.md new file mode 100644 index 0000000000..ee116020d4 --- /dev/null +++ b/storage_file_swap_backend_queue/readme/CONFIGURE.md @@ -0,0 +1,11 @@ +The batch size (number of files processed per job) can be tuned via the +system parameter: + +`storage_file_swap_backend_queue.swap_backend_batch_size` + +Default value is **5**. Set it in *Settings \> Technical \> Parameters +\> System Parameters* to adjust throughput vs. job granularity. + +A dedicated job channel `root.storage_file_swap` is created at install. +You can configure its concurrency in *Settings \> Technical \> Queue Job +\> Channels*. diff --git a/storage_file_swap_backend_queue/readme/DESCRIPTION.md b/storage_file_swap_backend_queue/readme/DESCRIPTION.md new file mode 100644 index 0000000000..f7d7b03bff --- /dev/null +++ b/storage_file_swap_backend_queue/readme/DESCRIPTION.md @@ -0,0 +1,7 @@ +This module integrates `storage_file` with `queue_job` to delegate the +backend swap operation to asynchronous jobs. + +When swapping files between storage backends via the wizard, the +operation is split into batches and dispatched as queue jobs instead of +running synchronously. This avoids timeouts when moving large numbers of +files. diff --git a/storage_file_swap_backend_queue/tests/__init__.py b/storage_file_swap_backend_queue/tests/__init__.py new file mode 100644 index 0000000000..f70da45146 --- /dev/null +++ b/storage_file_swap_backend_queue/tests/__init__.py @@ -0,0 +1 @@ +from . import test_swap_backend_queue diff --git a/storage_file_swap_backend_queue/tests/test_swap_backend_queue.py b/storage_file_swap_backend_queue/tests/test_swap_backend_queue.py new file mode 100644 index 0000000000..54b5e7d673 --- /dev/null +++ b/storage_file_swap_backend_queue/tests/test_swap_backend_queue.py @@ -0,0 +1,183 @@ +# Copyright 2026 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import base64 +from unittest import mock + +from odoo.tools import mute_logger + +from odoo.addons.component.tests.common import TransactionComponentCase +from odoo.addons.queue_job.tests.common import trap_jobs + + +class TestSwapBackendQueue(TransactionComponentCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.backend_a = cls.env.ref("storage_backend.default_storage_backend") + cls.backend_a.swap_backend_use_queue = True + cls.backend_b = cls.backend_a.copy( + { + "name": "Second Backend", + "directory_path": "backend_b", + } + ) + cls.backend_b.filename_strategy = "name_with_id" + + def _create_storage_file(self, name="my_file.txt", data=b"hello", backend=None): + return self.env["storage.file"].create( + { + "name": name, + "backend_id": (backend or self.backend_a).id, + "data": base64.b64encode(data), + } + ) + + def test_wizard_enqueues_jobs_split_by_batch(self): + """Wizard dispatches delayed jobs split by batch size (default 5).""" + files = self.env["storage.file"] + for i in range(12): + files |= self._create_storage_file(name=f"file_{i}.txt", data=b"data") + wiz = ( + self.env["storage.file.swap.backend"] + .with_context(active_model="storage.file", active_ids=files.ids) + .create({"dest_backend_id": self.backend_b.id}) + ) + with trap_jobs() as trap: + wiz.action_apply() + # 12 files / 5 per batch = 3 jobs + trap.assert_jobs_count(3) + # Perform them to verify they actually work + trap.perform_enqueued_jobs() + self.assertTrue(all(f.backend_id == self.backend_b for f in files)) + + def test_wizard_single_batch(self): + """A small recordset creates a single job.""" + files = self.env["storage.file"] + for i in range(3): + files |= self._create_storage_file(name=f"file_{i}.txt", data=b"data") + wiz = ( + self.env["storage.file.swap.backend"] + .with_context(active_model="storage.file", active_ids=files.ids) + .create({"dest_backend_id": self.backend_b.id}) + ) + with trap_jobs() as trap: + wiz.action_apply() + trap.assert_jobs_count(1) + + def test_wizard_batch_size_from_config_param(self): + """Batch size is read from ir.config_parameter.""" + self.env["ir.config_parameter"].sudo().set_param( + "storage_file_swap_backend_queue.swap_backend_batch_size", "3" + ) + self.env.registry.clear_cache() + files = self.env["storage.file"] + for i in range(7): + files |= self._create_storage_file(name=f"file_{i}.txt", data=b"data") + wiz = ( + self.env["storage.file.swap.backend"] + .with_context(active_model="storage.file", active_ids=files.ids) + .create({"dest_backend_id": self.backend_b.id}) + ) + with trap_jobs() as trap: + wiz.action_apply() + # 7 files / 3 per batch = 3 jobs + trap.assert_jobs_count(3) + + def test_job_moves_files_and_returns_summary(self): + """The job method moves files and returns a text summary.""" + stfile = self._create_storage_file(data=b"payload") + result = stfile._swap_backend_job(self.backend_b.id) + self.assertEqual(stfile.backend_id, self.backend_b) + self.assertIn(f"Moved to {self.backend_b.name} (1):", result) + self.assertIn(stfile.name, result) + + def test_job_deletes_old_file(self): + """The job deletes the old file directly.""" + stfile = self._create_storage_file(data=b"payload") + old_path = stfile.relative_path + stfile._swap_backend_job(self.backend_b.id) + with self.assertRaises(FileNotFoundError): + self.backend_a.sudo().get(old_path, binary=True) + + def test_job_skips_already_on_destination(self): + """Files already on dest backend produce 'Nothing to swap'.""" + stfile = self._create_storage_file(data=b"payload", backend=self.backend_b) + result = stfile._swap_backend_job(self.backend_b.id) + self.assertIn("Nothing to swap", result) + + def test_job_handles_missing_record(self): + """Deleted records between enqueue and execution are reported.""" + stfile = self._create_storage_file(data=b"payload") + file_id = stfile.id + stfile.with_context(cleanning_storage_file=True).unlink() + records = self.env["storage.file"].browse(file_id) + result = records._swap_backend_job(self.backend_b.id) + self.assertIn("no longer exists", result) + + def test_job_handles_upload_failure(self): + """Upload failures are caught and reported.""" + stfile = self._create_storage_file(data=b"payload") + with mock.patch.object( + type(self.env["storage.backend"]), + "add", + side_effect=RuntimeError("upload failed"), + ): + with mute_logger("odoo.addons.storage_file.models.storage_file"): + result = stfile._swap_backend_job(self.backend_b.id) + self.assertIn("Failed (1):", result) + self.assertIn("upload failed", result) + + def test_job_handles_missing_backend(self): + """If dest backend is deleted, the job returns an error message.""" + stfile = self._create_storage_file(data=b"payload") + result = stfile._swap_backend_job(99999) + self.assertIn("no longer exists", result) + + @mute_logger("odoo.addons.storage_file.models.storage_file") + def test_job_old_delete_failure_still_counts_as_moved(self): + """Failure to delete old file doesn't prevent success.""" + # logger muted to avoid warnings (and CI failure) w/ msg like + # Swapped my_file.txt but failed to delete old file... delete failed + stfile = self._create_storage_file(data=b"payload") + with mock.patch.object( + type(self.env["storage.backend"]), + "delete", + side_effect=RuntimeError("delete failed"), + ): + result = stfile._swap_backend_job(self.backend_b.id) + self.assertIn(f"Moved to {self.backend_b.name} (1):", result) + self.assertEqual(stfile.backend_id, self.backend_b) + + def test_wizard_use_queue_flag_from_backend(self): + """Wizard use_queue is preset from source backend flag.""" + stfile = self._create_storage_file(data=b"payload") + wiz = ( + self.env["storage.file.swap.backend"] + .with_context(active_model="storage.file", active_ids=stfile.ids) + .create({"dest_backend_id": self.backend_b.id}) + ) + self.assertTrue(wiz.use_queue) + # Disable queue on source backend + self.backend_a.swap_backend_use_queue = False + wiz2 = ( + self.env["storage.file.swap.backend"] + .with_context(active_model="storage.file", active_ids=stfile.ids) + .create({"dest_backend_id": self.backend_b.id}) + ) + self.assertFalse(wiz2.use_queue) + + def test_wizard_sync_when_queue_disabled(self): + """When use_queue is False, swap runs synchronously.""" + self.backend_a.swap_backend_use_queue = False + stfile = self._create_storage_file(data=b"payload") + wiz = ( + self.env["storage.file.swap.backend"] + .with_context(active_model="storage.file", active_ids=stfile.ids) + .create({"dest_backend_id": self.backend_b.id}) + ) + with trap_jobs() as trap: + wiz.action_apply() + trap.assert_jobs_count(0) + self.assertEqual(stfile.backend_id, self.backend_b) diff --git a/storage_file_swap_backend_queue/views/storage_backend_view.xml b/storage_file_swap_backend_queue/views/storage_backend_view.xml new file mode 100644 index 0000000000..9552d63e3f --- /dev/null +++ b/storage_file_swap_backend_queue/views/storage_backend_view.xml @@ -0,0 +1,15 @@ + + + + + storage.backend + + + + + + + + diff --git a/storage_file_swap_backend_queue/views/swap_backend_view.xml b/storage_file_swap_backend_queue/views/swap_backend_view.xml new file mode 100644 index 0000000000..b03a1499f5 --- /dev/null +++ b/storage_file_swap_backend_queue/views/swap_backend_view.xml @@ -0,0 +1,18 @@ + + + + + storage.file.swap.backend + + + + + + + + diff --git a/storage_file_swap_backend_queue/wizards/__init__.py b/storage_file_swap_backend_queue/wizards/__init__.py new file mode 100644 index 0000000000..8cc4b7c7dd --- /dev/null +++ b/storage_file_swap_backend_queue/wizards/__init__.py @@ -0,0 +1 @@ +from . import swap_backend diff --git a/storage_file_swap_backend_queue/wizards/swap_backend.py b/storage_file_swap_backend_queue/wizards/swap_backend.py new file mode 100644 index 0000000000..638567da80 --- /dev/null +++ b/storage_file_swap_backend_queue/wizards/swap_backend.py @@ -0,0 +1,30 @@ +# Copyright 2026 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import fields, models + +DEFAULT_SWAP_BATCH_SIZE = 5 +SWAP_BATCH_SIZE_PARAM = "storage_file_swap_backend_queue.swap_backend_batch_size" + + +class StorageFileSwapBackend(models.TransientModel): + _inherit = "storage.file.swap.backend" + + use_queue = fields.Boolean( + related="source_backend_id.swap_backend_use_queue", + string="Use Queue Jobs", + ) + + def _action_apply(self): + """Override to dispatch swap via queue jobs instead of synchronous.""" + if not self.use_queue: + return super()._action_apply() + batch_size = int( + self.env["ir.config_parameter"] + .sudo() + .get_param(SWAP_BATCH_SIZE_PARAM, DEFAULT_SWAP_BATCH_SIZE) + ) + self.file_ids.delayable()._swap_backend_job(self.dest_backend_id.id).split( + batch_size + ).delay() From 770b41ef0213b95a7dbf7205a935b8a8c454ee3c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 29 May 2026 09:47:23 +0200 Subject: [PATCH 06/16] [IMP] storage_file: swap backend on backend_id update too In case you change the backend directly from form or not. --- storage_file/models/storage_file.py | 33 ++++++++++++++++++++++--- storage_file/tests/test_swap_backend.py | 20 +++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/storage_file/models/storage_file.py b/storage_file/models/storage_file.py index d8a2949405..f7f23614ec 100644 --- a/storage_file/models/storage_file.py +++ b/storage_file/models/storage_file.py @@ -83,6 +83,19 @@ def write(self, vals): "File can not be updated, remove it and create a new one" ) ) + if "backend_id" in vals and not self.env.context.get( + "storage_file_swap_backend" + ): + new_backend = self.env["storage.backend"].browse(vals["backend_id"]) + to_swap = self.filtered( + lambda r: r.backend_id and r.backend_id != new_backend + ) + if to_swap: + to_swap._swap_backend(new_backend) + remaining = self - to_swap + if remaining: + return super(StorageFile, remaining).write(vals) + return True return super().write(vals) @api.depends("file_size") @@ -259,19 +272,31 @@ def _swap_backend(self, new_backend): old_relative_path = record.relative_path try: if not old_relative_path: - record.backend_id = new_backend + record.with_context( + storage_file_swap_backend=True + ).backend_id = new_backend moved.append(f"{record.name} (ID {record.id})") continue bin_data = old_backend.get(old_relative_path, binary=True) - record.backend_id = new_backend - new_relative_path = record._build_relative_path(record.checksum) + # Same logic as _build_relative_path but using the target + # backend strategy (backend_id not yet reassigned). + strategy = new_backend.filename_strategy + if strategy == "hash": + new_relative_path = record.checksum[:2] + "/" + record.checksum + else: + new_relative_path = record.slug new_backend.add( new_relative_path, bin_data, mimetype=record.mimetype, binary=True, ) - record.relative_path = new_relative_path + record.with_context(storage_file_swap_backend=True).write( + { + "backend_id": new_backend.id, + "relative_path": new_relative_path, + } + ) try: old_backend.delete(old_relative_path) except Exception as exc: diff --git a/storage_file/tests/test_swap_backend.py b/storage_file/tests/test_swap_backend.py index cc82db284a..5575be4b77 100644 --- a/storage_file/tests/test_swap_backend.py +++ b/storage_file/tests/test_swap_backend.py @@ -184,3 +184,23 @@ def test_wizard_form_loads_with_source_backend(self): ) as wiz_form: self.assertEqual(wiz_form.source_backend_id, self.backend_a) wiz_form.dest_backend_id = self.backend_b + + # -- write triggers swap ------------------------------------------------ + + def test_write_backend_id_triggers_swap(self): + """Writing backend_id on storage.file triggers the full swap.""" + stfile = self._create_storage_file(data=b"payload") + old_path = stfile.relative_path + stfile.backend_id = self.backend_b + self.assertEqual(stfile.backend_id, self.backend_b) + self.assertEqual(base64.b64decode(stfile.data), b"payload") + with self.assertRaises(FileNotFoundError): + self.backend_a.sudo().get(old_path, binary=True) + + def test_write_backend_id_noop_if_same(self): + """Writing same backend_id does nothing special.""" + stfile = self._create_storage_file(data=b"payload") + old_path = stfile.relative_path + stfile.backend_id = self.backend_a + self.assertEqual(stfile.backend_id, self.backend_a) + self.assertEqual(stfile.relative_path, old_path) From cc833f2625374c6e3f01d69ef26934860afbb9dd Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 12 May 2026 18:33:36 +0200 Subject: [PATCH 07/16] [ADD] storage_image: implement swap backend wizard --- storage_image/__manifest__.py | 1 + storage_image/readme/CONTRIBUTORS.md | 1 + storage_image/tests/__init__.py | 1 + storage_image/tests/test_swap_backend.py | 55 ++++++++++++++++++++++++ storage_image/wizards/swap_backend.xml | 24 +++++++++++ 5 files changed, 82 insertions(+) create mode 100644 storage_image/tests/test_swap_backend.py create mode 100644 storage_image/wizards/swap_backend.xml diff --git a/storage_image/__manifest__.py b/storage_image/__manifest__.py index cf2c755a1d..ab430ad45b 100644 --- a/storage_image/__manifest__.py +++ b/storage_image/__manifest__.py @@ -17,6 +17,7 @@ "security/ir_rule.xml", "security/ir.model.access.csv", "wizards/replace_file.xml", + "wizards/swap_backend.xml", "views/storage_image.xml", "views/storage_image_relation_abstract.xml", "data/ir_config_parameter.xml", diff --git a/storage_image/readme/CONTRIBUTORS.md b/storage_image/readme/CONTRIBUTORS.md index 7aee706c1b..3259b1ba45 100644 --- a/storage_image/readme/CONTRIBUTORS.md +++ b/storage_image/readme/CONTRIBUTORS.md @@ -6,4 +6,5 @@ - Quentin Groulard \<\> - [Camptocamp](https://www.camptocamp.com) - Iván Todorovich \<\> + - Simone Orsi \<\> - Vo Hong Thien \<\> diff --git a/storage_image/tests/__init__.py b/storage_image/tests/__init__.py index 9271a8d2bd..cedd944b24 100644 --- a/storage_image/tests/__init__.py +++ b/storage_image/tests/__init__.py @@ -1,3 +1,4 @@ from . import common from . import test_storage_image from . import test_storage_replace_file +from . import test_swap_backend diff --git a/storage_image/tests/test_swap_backend.py b/storage_image/tests/test_swap_backend.py new file mode 100644 index 0000000000..6f91830b79 --- /dev/null +++ b/storage_image/tests/test_swap_backend.py @@ -0,0 +1,55 @@ +# Copyright 2026 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from .common import StorageImageCommonCase + + +class TestSwapBackend(StorageImageCommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.backend_b = cls.backend.sudo().copy( + { + "name": "Second Backend", + "directory_path": "image_backend_b", + } + ) + + def test_server_action_swaps_images(self): + image = self._create_storage_image_from_file("static/akretion-logo.png") + action = self.env.ref( + "storage_image.storage_image_swap_backend_server_action" + ).sudo() + ctx = { + "active_model": "storage.image", + "active_ids": image.ids, + "active_id": image.id, + } + result = action.with_context(**ctx).run() + wizard_action = result + # Server action returns an action dict; its context targets storage.file + # with the image's file_id. + self.assertEqual(wizard_action["res_model"], "storage.file.swap.backend") + self.assertEqual(wizard_action["context"]["active_model"], "storage.file") + self.assertEqual(wizard_action["context"]["active_ids"], image.file_id.ids) + wiz = ( + self.env["storage.file.swap.backend"] + .sudo() + .with_context(**wizard_action["context"]) + .create({"dest_backend_id": self.backend_b.id}) + ) + self.assertEqual(wiz.source_backend_id, image.file_id.backend_id) + wiz.action_apply() + self.assertEqual(image.file_id.backend_id, self.backend_b) + + def test_wizard_resolves_file_ids_from_image_model(self): + """The base wizard's fallback maps storage.image -> file_id.""" + image = self._create_storage_image_from_file("static/akretion-logo.png") + wiz = ( + self.env["storage.file.swap.backend"] + .sudo() + .with_context(active_model="storage.image", active_ids=image.ids) + .create({}) + ) + self.assertEqual(wiz.file_ids, image.file_id) diff --git a/storage_image/wizards/swap_backend.xml b/storage_image/wizards/swap_backend.xml new file mode 100644 index 0000000000..dc28a1491b --- /dev/null +++ b/storage_image/wizards/swap_backend.xml @@ -0,0 +1,24 @@ + + + + + Swap Storage Backend + + + list,form + code + +action = env["ir.actions.act_window"]._for_xml_id( + "storage_file.storage_file_swap_backend_action" +) +file_ids = env["storage.image"].browse(env.context.get("active_ids") or []).mapped("file_id").ids +action["context"] = { + "active_model": "storage.file", + "active_ids": file_ids, + "active_id": file_ids[0] if file_ids else False, +} + + + From 57b2d970bbc57be40ca6f9685ea88aa6bbb89c2d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 12 May 2026 18:34:11 +0200 Subject: [PATCH 08/16] [ADD] storage_media: implement swap backend wizard --- storage_media/__manifest__.py | 1 + storage_media/tests/__init__.py | 1 + storage_media/tests/test_swap_backend.py | 56 ++++++++++++++++++++++++ storage_media/wizards/swap_backend.xml | 24 ++++++++++ 4 files changed, 82 insertions(+) create mode 100644 storage_media/tests/test_swap_backend.py create mode 100644 storage_media/wizards/swap_backend.xml diff --git a/storage_media/__manifest__.py b/storage_media/__manifest__.py index 6d93d86439..cdedd32503 100644 --- a/storage_media/__manifest__.py +++ b/storage_media/__manifest__.py @@ -16,6 +16,7 @@ "depends": ["storage_file", "storage_thumbnail"], "data": [ "wizards/replace_file.xml", + "wizards/swap_backend.xml", "views/storage_media_view.xml", "data/ir_parameter.xml", "security/res_group.xml", diff --git a/storage_media/tests/__init__.py b/storage_media/tests/__init__.py index d4090db9f5..3a133375d2 100644 --- a/storage_media/tests/__init__.py +++ b/storage_media/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_storage_media from . import test_storage_replace_file +from . import test_swap_backend diff --git a/storage_media/tests/test_swap_backend.py b/storage_media/tests/test_swap_backend.py new file mode 100644 index 0000000000..590becb0f2 --- /dev/null +++ b/storage_media/tests/test_swap_backend.py @@ -0,0 +1,56 @@ +# Copyright 2026 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import base64 + +from odoo.addons.component.tests.common import TransactionComponentCase + + +class TestSwapBackend(TransactionComponentCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.backend_a = cls.env.ref("storage_backend.default_storage_backend") + cls.backend_b = cls.backend_a.sudo().copy( + { + "name": "Second Backend", + "directory_path": "media_backend_b", + } + ) + + def _create_media(self, name="m1.txt", data=b"data"): + return self.env["storage.media"].create( + {"name": name, "data": base64.b64encode(data)} + ) + + def test_server_action_swaps_media(self): + media = self._create_media() + action = self.env.ref( + "storage_media.storage_media_swap_backend_server_action" + ).sudo() + result = action.with_context( + active_model="storage.media", + active_ids=media.ids, + active_id=media.id, + ).run() + self.assertEqual(result["res_model"], "storage.file.swap.backend") + self.assertEqual(result["context"]["active_ids"], media.file_id.ids) + wiz = ( + self.env["storage.file.swap.backend"] + .sudo() + .with_context(**result["context"]) + .create({"dest_backend_id": self.backend_b.id}) + ) + wiz.action_apply() + self.assertEqual(media.file_id.backend_id, self.backend_b) + + def test_wizard_resolves_file_ids_from_media_model(self): + media = self._create_media() + wiz = ( + self.env["storage.file.swap.backend"] + .sudo() + .with_context(active_model="storage.media", active_ids=media.ids) + .create({}) + ) + self.assertEqual(wiz.file_ids, media.file_id) diff --git a/storage_media/wizards/swap_backend.xml b/storage_media/wizards/swap_backend.xml new file mode 100644 index 0000000000..9cbd148150 --- /dev/null +++ b/storage_media/wizards/swap_backend.xml @@ -0,0 +1,24 @@ + + + + + Swap Storage Backend + + + list,form + code + +action = env["ir.actions.act_window"]._for_xml_id( + "storage_file.storage_file_swap_backend_action" +) +file_ids = env["storage.media"].browse(env.context.get("active_ids") or []).mapped("file_id").ids +action["context"] = { + "active_model": "storage.file", + "active_ids": file_ids, + "active_id": file_ids[0] if file_ids else False, +} + + + From 9c5e2ba8104b1add2fb0e0e135b494382d3fd687 Mon Sep 17 00:00:00 2001 From: oca-ci Date: Wed, 10 Jun 2026 10:14:14 +0000 Subject: [PATCH 09/16] [UPD] Update storage_file.pot --- storage_file/i18n/storage_file.pot | 88 ++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/storage_file/i18n/storage_file.pot b/storage_file/i18n/storage_file.pot index 92408396be..4274550bc2 100644 --- a/storage_file/i18n/storage_file.pot +++ b/storage_file/i18n/storage_file.pot @@ -13,6 +13,12 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" +#. module: storage_file +#. odoo-python +#: code:addons/storage_file/models/storage_file.py:0 +msgid "A destination storage is required." +msgstr "" + #. module: storage_file #: model:ir.model.fields,help:storage_file.field_storage_file__url_path msgid "Accessible path, no base URL" @@ -23,6 +29,14 @@ msgstr "" msgid "Active" msgstr "" +#. module: storage_file +#. odoo-python +#: code:addons/storage_file/wizards/swap_backend.py:0 +msgid "" +"All selected records must belong to the same source storage backend. Found: " +"%s" +msgstr "" + #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_backend__backend_view_use_internal_url msgid "Backend View Use Internal Url" @@ -43,6 +57,11 @@ msgstr "" msgid "Base Url For Files" msgstr "" +#. module: storage_file +#: model_terms:ir.ui.view,arch_db:storage_file.storage_file_swap_backend_view_form +msgid "Cancel" +msgstr "" + #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_file__checksum msgid "Checksum/SHA1" @@ -61,12 +80,14 @@ msgstr "" #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_file__create_uid #: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__create_uid +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__create_uid msgid "Created by" msgstr "" #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_file__create_date #: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__create_date +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__create_date msgid "Created on" msgstr "" @@ -93,9 +114,21 @@ msgid "" "Public: your file/image can be displayed if nobody is logged (useful to display files on external websites)" msgstr "" +#. module: storage_file +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__dest_backend_id +msgid "Destination Storage" +msgstr "" + +#. module: storage_file +#. odoo-python +#: code:addons/storage_file/wizards/swap_backend.py:0 +msgid "Destination storage must differ from source." +msgstr "" + #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_file__display_name #: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__display_name +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__display_name msgid "Display Name" msgstr "" @@ -149,6 +182,16 @@ msgstr "" msgid "Filename without extension" msgstr "" +#. module: storage_file +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__file_ids +msgid "Files" +msgstr "" + +#. module: storage_file +#: model_terms:ir.ui.view,arch_db:storage_file.storage_file_swap_backend_view_form +msgid "Files to swap" +msgstr "" + #. module: storage_file #: model:ir.model.fields,help:storage_file.field_storage_file__internal_url msgid "HTTP URL to load the file directly from storage." @@ -167,6 +210,7 @@ msgstr "" #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_file__id #: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__id +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__id msgid "ID" msgstr "" @@ -190,12 +234,14 @@ msgstr "" #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_file__write_uid #: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__write_uid +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__write_uid msgid "Last Updated by" msgstr "" #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_file__write_date #: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__write_date +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__write_date msgid "Last Updated on" msgstr "" @@ -226,6 +272,18 @@ msgstr "" msgid "Odoo" msgstr "" +#. module: storage_file +#. odoo-python +#: code:addons/storage_file/wizards/swap_backend.py:0 +msgid "Please select a destination storage." +msgstr "" + +#. module: storage_file +#. odoo-python +#: code:addons/storage_file/wizards/swap_backend.py:0 +msgid "Please select at least one file." +msgstr "" + #. module: storage_file #: model_terms:ir.ui.view,arch_db:storage_file.storage_backend_view_form msgid "Recompute base URL for files" @@ -274,6 +332,11 @@ msgstr "" msgid "Slug-ified name with ID for URL" msgstr "" +#. module: storage_file +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__source_backend_id +msgid "Source Storage" +msgstr "" + #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_file__backend_id msgid "Storage" @@ -297,6 +360,23 @@ msgid "" "SHA Hash: will use the hash of the file as filename (same method as the native attachment storage)" msgstr "" +#. module: storage_file +#: model_terms:ir.ui.view,arch_db:storage_file.storage_file_swap_backend_view_form +msgid "Swap Files" +msgstr "" + +#. module: storage_file +#: model:ir.actions.act_window,name:storage_file.storage_file_swap_backend_action +#: model:ir.actions.server,name:storage_file.storage_file_swap_backend_server_action +#: model_terms:ir.ui.view,arch_db:storage_file.storage_file_swap_backend_view_form +msgid "Swap Storage Backend" +msgstr "" + +#. module: storage_file +#: model:ir.model,name:storage_file.model_storage_file_swap_backend +msgid "Swap storage files between backends" +msgstr "" + #. module: storage_file #. odoo-python #: code:addons/storage_file/models/storage_file.py:0 @@ -305,6 +385,14 @@ msgid "" "Please configure it" msgstr "" +#. module: storage_file +#. odoo-python +#: code:addons/storage_file/models/storage_file.py:0 +msgid "" +"The filename strategy is empty for the backend %s.\n" +"Please configure it." +msgstr "" + #. module: storage_file #: model:ir.model.constraint,message:storage_file.constraint_storage_file_path_uniq msgid "The private path must be uniq per backend" From fe2bc6414e7387fd202a0fd260adcb69ca0e9b91 Mon Sep 17 00:00:00 2001 From: oca-ci Date: Wed, 10 Jun 2026 10:14:15 +0000 Subject: [PATCH 10/16] [UPD] Update storage_file_swap_backend_queue.pot --- .../i18n/storage_file_swap_backend_queue.pot | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 storage_file_swap_backend_queue/i18n/storage_file_swap_backend_queue.pot diff --git a/storage_file_swap_backend_queue/i18n/storage_file_swap_backend_queue.pot b/storage_file_swap_backend_queue/i18n/storage_file_swap_backend_queue.pot new file mode 100644 index 0000000000..0e3f13a45a --- /dev/null +++ b/storage_file_swap_backend_queue/i18n/storage_file_swap_backend_queue.pot @@ -0,0 +1,53 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * storage_file_swap_backend_queue +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: storage_file_swap_backend_queue +#. odoo-python +#: code:addons/storage_file_swap_backend_queue/models/storage_file.py:0 +msgid "Destination backend id=%(backend_id)d no longer exists." +msgstr "" + +#. module: storage_file_swap_backend_queue +#: model:ir.model,name:storage_file_swap_backend_queue.model_storage_backend +msgid "Storage Backend" +msgstr "" + +#. module: storage_file_swap_backend_queue +#: model:ir.model,name:storage_file_swap_backend_queue.model_storage_file +msgid "Storage File" +msgstr "" + +#. module: storage_file_swap_backend_queue +#: model:ir.model,name:storage_file_swap_backend_queue.model_storage_file_swap_backend +msgid "Swap storage files between backends" +msgstr "" + +#. module: storage_file_swap_backend_queue +#: model:ir.model.fields,field_description:storage_file_swap_backend_queue.field_storage_file_swap_backend__use_queue +msgid "Use Queue Jobs" +msgstr "" + +#. module: storage_file_swap_backend_queue +#: model:ir.model.fields,field_description:storage_file_swap_backend_queue.field_storage_backend__swap_backend_use_queue +msgid "Use Queue for Backend Swap" +msgstr "" + +#. module: storage_file_swap_backend_queue +#: model:ir.model.fields,help:storage_file_swap_backend_queue.field_storage_backend__swap_backend_use_queue +#: model:ir.model.fields,help:storage_file_swap_backend_queue.field_storage_file_swap_backend__use_queue +msgid "" +"When enabled, swapping files to/from this backend will be dispatched as " +"asynchronous queue jobs instead of running synchronously." +msgstr "" From dce455350f8da57414e573ee38aa373979827245 Mon Sep 17 00:00:00 2001 From: oca-ci Date: Wed, 10 Jun 2026 10:14:15 +0000 Subject: [PATCH 11/16] [UPD] Update storage_image.pot --- storage_image/i18n/storage_image.pot | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/storage_image/i18n/storage_image.pot b/storage_image/i18n/storage_image.pot index 155d4ff581..8fdd8cf1fe 100644 --- a/storage_image/i18n/storage_image.pot +++ b/storage_image/i18n/storage_image.pot @@ -246,6 +246,11 @@ msgstr "" msgid "Storage Image" msgstr "" +#. module: storage_image +#: model:ir.actions.server,name:storage_image.storage_image_swap_backend_server_action +msgid "Swap Storage Backend" +msgstr "" + #. module: storage_image #: model:ir.model.fields,field_description:storage_image.field_storage_image__thumb_medium_id msgid "Thumb Medium" From 552653cae39cdfdf1a37aabbdf19568ba5c870e3 Mon Sep 17 00:00:00 2001 From: oca-ci Date: Wed, 10 Jun 2026 10:14:16 +0000 Subject: [PATCH 12/16] [UPD] Update storage_media.pot --- storage_media/i18n/storage_media.pot | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/storage_media/i18n/storage_media.pot b/storage_media/i18n/storage_media.pot index 1ddaea4ae3..dac136a245 100644 --- a/storage_media/i18n/storage_media.pot +++ b/storage_media/i18n/storage_media.pot @@ -233,6 +233,11 @@ msgstr "" msgid "Storage Media Type" msgstr "" +#. module: storage_media +#: model:ir.actions.server,name:storage_media.storage_media_swap_backend_server_action +msgid "Swap Storage Backend" +msgstr "" + #. module: storage_media #: model:ir.model.fields,field_description:storage_media.field_storage_media__to_delete msgid "To Delete" From 5c15d03247ad79f6b820682817a332c65365d398 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 10 Jun 2026 10:18:25 +0000 Subject: [PATCH 13/16] [BOT] post-merge updates --- README.md | 7 +- setup/_metapackage/pyproject.toml | 3 +- storage_file/README.rst | 9 +- storage_file/__manifest__.py | 2 +- storage_file/static/description/index.html | 29 +- storage_file_swap_backend_queue/README.rst | 105 ++++- .../__manifest__.py | 2 +- .../static/description/icon.png | Bin 0 -> 10254 bytes .../static/description/index.html | 441 ++++++++++++++++++ storage_image/README.rst | 3 +- storage_image/__manifest__.py | 2 +- storage_image/static/description/index.html | 3 +- storage_media/__manifest__.py | 2 +- 13 files changed, 584 insertions(+), 24 deletions(-) create mode 100644 storage_file_swap_backend_queue/static/description/icon.png create mode 100644 storage_file_swap_backend_queue/static/description/index.html diff --git a/README.md b/README.md index c9d5bb15d2..28b94bf101 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,11 @@ addon | version | maintainers | summary [storage_backend_ftp](storage_backend_ftp/) | 18.0.1.0.0 | | Implement FTP Storage [storage_backend_s3](storage_backend_s3/) | 18.0.1.1.0 | | Implement amazon S3 Storage [storage_backend_sftp](storage_backend_sftp/) | 18.0.1.0.0 | | Implement SFTP Storage -[storage_file](storage_file/) | 18.0.1.0.0 | | Storage file in storage backend -[storage_image](storage_image/) | 18.0.1.0.1 | | Store image and resized image in a storage backend +[storage_file](storage_file/) | 18.0.1.1.0 | | Storage file in storage backend +[storage_file_swap_backend_queue](storage_file_swap_backend_queue/) | 18.0.1.1.0 | simahawk | Delegate storage file backend swap to queue jobs +[storage_image](storage_image/) | 18.0.1.1.0 | | Store image and resized image in a storage backend [storage_image_product](storage_image_product/) | 18.0.1.0.2 | | Link images to products and categories -[storage_media](storage_media/) | 18.0.1.1.2 | | Give the posibility to store media data in Odoo +[storage_media](storage_media/) | 18.0.1.2.0 | | Give the posibility to store media data in Odoo [storage_media_product](storage_media_product/) | 18.0.1.0.1 | | Link media to products and categories [storage_thumbnail](storage_thumbnail/) | 18.0.1.0.0 | | Abstract module that add the possibility to have thumbnail diff --git a/setup/_metapackage/pyproject.toml b/setup/_metapackage/pyproject.toml index aab97dd74c..810273a1ce 100644 --- a/setup/_metapackage/pyproject.toml +++ b/setup/_metapackage/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "odoo-addons-oca-storage" -version = "18.0.20260527.0" +version = "18.0.20260610.0" dependencies = [ "odoo-addon-fs_attachment==18.0.*", "odoo-addon-fs_attachment_s3==18.0.*", @@ -20,6 +20,7 @@ dependencies = [ "odoo-addon-storage_backend_s3==18.0.*", "odoo-addon-storage_backend_sftp==18.0.*", "odoo-addon-storage_file==18.0.*", + "odoo-addon-storage_file_swap_backend_queue==18.0.*", "odoo-addon-storage_image==18.0.*", "odoo-addon-storage_image_product==18.0.*", "odoo-addon-storage_media==18.0.*", diff --git a/storage_file/README.rst b/storage_file/README.rst index 0c97007895..d17d2cc992 100644 --- a/storage_file/README.rst +++ b/storage_file/README.rst @@ -1,3 +1,7 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + ============ Storage File ============ @@ -7,13 +11,13 @@ Storage File !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:99da3076f6d478ec2d159ca6dcc9f7fb7cebc5b016caf82b184ed032defeee28 + !! source digest: sha256:14eff9ac3e34f90b55e98e5f1d8e938390db9e5d7c2f421d370143c3a26f5e1b !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png :target: https://odoo-community.org/page/development-status :alt: Production/Stable -.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html :alt: License: LGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstorage-lightgray.png?logo=github @@ -66,6 +70,7 @@ Contributors - Sebastien Beau - Raphaël Reverdy - Vo Hong Thien +- Simone Orsi Other credits ------------- diff --git a/storage_file/__manifest__.py b/storage_file/__manifest__.py index fbeb447a37..91abd363a5 100644 --- a/storage_file/__manifest__.py +++ b/storage_file/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Storage File", "summary": "Storage file in storage backend", - "version": "18.0.1.0.0", + "version": "18.0.1.1.0", "category": "Storage", "website": "https://github.com/OCA/storage", "author": " Akretion, Odoo Community Association (OCA)", diff --git a/storage_file/static/description/index.html b/storage_file/static/description/index.html index e5f9e8e083..50e1156ab5 100644 --- a/storage_file/static/description/index.html +++ b/storage_file/static/description/index.html @@ -3,7 +3,7 @@ -Storage File +README.rst -
-

Storage File

+
+ + +Odoo Community Association + +
+

Storage File

-

Production/Stable License: LGPL-3 OCA/storage Translate me on Weblate Try me on Runboat

+

Production/Stable License: LGPL-3 OCA/storage Translate me on Weblate Try me on Runboat

External file management depending on Storage Backend module.

It include these features: * link to any Odoo model/record * store metadata like: checksum, mimetype

@@ -390,7 +395,7 @@

Storage File

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -398,28 +403,29 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Akretion
-

Other credits

+

Other credits

The migration of this module from 15.0 to 18.0 was financially supported by Camptocamp

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -432,5 +438,6 @@

Maintainers

+
diff --git a/storage_file_swap_backend_queue/README.rst b/storage_file_swap_backend_queue/README.rst index 1333ed77b7..c189e464ac 100644 --- a/storage_file_swap_backend_queue/README.rst +++ b/storage_file_swap_backend_queue/README.rst @@ -1 +1,104 @@ -TODO +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=============================== +Storage File Swap Backend Queue +=============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:88d0c5f287ae9fe618d5d9e42c2f27909cfba41a1242444d8ca1e7dc365c9765 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstorage-lightgray.png?logo=github + :target: https://github.com/OCA/storage/tree/18.0/storage_file_swap_backend_queue + :alt: OCA/storage +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/storage-18-0/storage-18-0-storage_file_swap_backend_queue + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/storage&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module integrates ``storage_file`` with ``queue_job`` to delegate +the backend swap operation to asynchronous jobs. + +When swapping files between storage backends via the wizard, the +operation is split into batches and dispatched as queue jobs instead of +running synchronously. This avoids timeouts when moving large numbers of +files. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +The batch size (number of files processed per job) can be tuned via the +system parameter: + +``storage_file_swap_backend_queue.swap_backend_batch_size`` + +Default value is **5**. Set it in *Settings > Technical > Parameters > +System Parameters* to adjust throughput vs. job granularity. + +A dedicated job channel ``root.storage_file_swap`` is created at +install. You can configure its concurrency in *Settings > Technical > +Queue Job > Channels*. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +.. |maintainer-simahawk| image:: https://github.com/simahawk.png?size=40px + :target: https://github.com/simahawk + :alt: simahawk + +Current `maintainer `__: + +|maintainer-simahawk| + +This module is part of the `OCA/storage `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/storage_file_swap_backend_queue/__manifest__.py b/storage_file_swap_backend_queue/__manifest__.py index c7ef6b09ad..326172c51a 100644 --- a/storage_file_swap_backend_queue/__manifest__.py +++ b/storage_file_swap_backend_queue/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Storage File Swap Backend Queue", "summary": "Delegate storage file backend swap to queue jobs", - "version": "18.0.1.0.0", + "version": "18.0.1.1.0", "category": "Storage", "website": "https://github.com/OCA/storage", "author": "Camptocamp, Odoo Community Association (OCA)", diff --git a/storage_file_swap_backend_queue/static/description/icon.png b/storage_file_swap_backend_queue/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1dcc49c24f364e9adf0afbc6fc0bac6dbecdeb11 GIT binary patch literal 10254 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 + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Storage File Swap Backend Queue

+ +

Beta License: LGPL-3 OCA/storage Translate me on Weblate Try me on Runboat

+

This module integrates storage_file with queue_job to delegate +the backend swap operation to asynchronous jobs.

+

When swapping files between storage backends via the wizard, the +operation is split into batches and dispatched as queue jobs instead of +running synchronously. This avoids timeouts when moving large numbers of +files.

+

Table of contents

+ +
+

Configuration

+

The batch size (number of files processed per job) can be tuned via the +system parameter:

+

storage_file_swap_backend_queue.swap_backend_batch_size

+

Default value is 5. Set it in Settings > Technical > Parameters > +System Parameters to adjust throughput vs. job granularity.

+

A dedicated job channel root.storage_file_swap is created at +install. You can configure its concurrency in Settings > Technical > +Queue Job > Channels.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +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.

+

Current maintainer:

+

simahawk

+

This module is part of the OCA/storage project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/storage_image/README.rst b/storage_image/README.rst index 82c6c13b4a..b11178c3c2 100644 --- a/storage_image/README.rst +++ b/storage_image/README.rst @@ -11,7 +11,7 @@ Storage Image !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:593805be88722deb95e2e6383ff11aac5b2f8d5e4218cc5cf14875b4b18872a9 + !! source digest: sha256:288cb6384d178a0baa57c73bf92d5ab94f325c0ff7e347bd72c79da2d67e18fa !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png @@ -79,6 +79,7 @@ Contributors - `Camptocamp `__ - Iván Todorovich + - Simone Orsi - Vo Hong Thien diff --git a/storage_image/__manifest__.py b/storage_image/__manifest__.py index ab430ad45b..39063806b7 100644 --- a/storage_image/__manifest__.py +++ b/storage_image/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Storage Image", "summary": "Store image and resized image in a storage backend", - "version": "18.0.1.0.1", + "version": "18.0.1.1.0", "category": "Storage", "website": "https://github.com/OCA/storage", "author": " Akretion, Odoo Community Association (OCA)", diff --git a/storage_image/static/description/index.html b/storage_image/static/description/index.html index 74c81bf929..32df5a2856 100644 --- a/storage_image/static/description/index.html +++ b/storage_image/static/description/index.html @@ -372,7 +372,7 @@

Storage Image

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:593805be88722deb95e2e6383ff11aac5b2f8d5e4218cc5cf14875b4b18872a9 +!! source digest: sha256:288cb6384d178a0baa57c73bf92d5ab94f325c0ff7e347bd72c79da2d67e18fa !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Production/Stable License: LGPL-3 OCA/storage Translate me on Weblate Try me on Runboat

External image management depending on Storage File module.

@@ -426,6 +426,7 @@

Contributors

  • Quentin Groulard <quentin.groulard@acsone.eu>
  • Camptocamp
  • Vo Hong Thien <thienvh@trobz.com>
  • diff --git a/storage_media/__manifest__.py b/storage_media/__manifest__.py index cdedd32503..482e6edda7 100644 --- a/storage_media/__manifest__.py +++ b/storage_media/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Storage Media", "summary": "Give the posibility to store media data in Odoo", - "version": "18.0.1.1.2", + "version": "18.0.1.2.0", "category": "Uncategorized", "website": "https://github.com/OCA/storage", "author": " Akretion, Odoo Community Association (OCA)", From 509a63c518650dd4a75a84b2213af87b98bd0da0 Mon Sep 17 00:00:00 2001 From: Weblate Date: Wed, 10 Jun 2026 10:18:40 +0000 Subject: [PATCH 14/16] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: storage-18.0/storage-18.0-storage_image Translate-URL: https://translation.odoo-community.org/projects/storage-18-0/storage-18-0-storage_image/ --- storage_image/i18n/it.po | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/storage_image/i18n/it.po b/storage_image/i18n/it.po index bc43c16bc1..8c6e824569 100644 --- a/storage_image/i18n/it.po +++ b/storage_image/i18n/it.po @@ -249,6 +249,11 @@ msgstr "File deposito" msgid "Storage Image" msgstr "Immagine deposito" +#. module: storage_image +#: model:ir.actions.server,name:storage_image.storage_image_swap_backend_server_action +msgid "Swap Storage Backend" +msgstr "" + #. module: storage_image #: model:ir.model.fields,field_description:storage_image.field_storage_image__thumb_medium_id msgid "Thumb Medium" From 129402ca509e40ba248a3e38cd76cfa84e52716d Mon Sep 17 00:00:00 2001 From: Weblate Date: Wed, 10 Jun 2026 10:18:40 +0000 Subject: [PATCH 15/16] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: storage-18.0/storage-18.0-storage_media Translate-URL: https://translation.odoo-community.org/projects/storage-18-0/storage-18-0-storage_media/ --- storage_media/i18n/it.po | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/storage_media/i18n/it.po b/storage_media/i18n/it.po index 242b89a1c2..f1485757cb 100644 --- a/storage_media/i18n/it.po +++ b/storage_media/i18n/it.po @@ -236,6 +236,11 @@ msgstr "Gestore deposito media" msgid "Storage Media Type" msgstr "Tipo media deposito" +#. module: storage_media +#: model:ir.actions.server,name:storage_media.storage_media_swap_backend_server_action +msgid "Swap Storage Backend" +msgstr "" + #. module: storage_media #: model:ir.model.fields,field_description:storage_media.field_storage_media__to_delete msgid "To Delete" From b0f004c23421853de8729c6e14023e1180f3b28e Mon Sep 17 00:00:00 2001 From: Weblate Date: Wed, 10 Jun 2026 10:18:40 +0000 Subject: [PATCH 16/16] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: storage-18.0/storage-18.0-storage_file Translate-URL: https://translation.odoo-community.org/projects/storage-18-0/storage-18-0-storage_file/ --- storage_file/i18n/it.po | 124 +++++++++++++++++++++++++++++++++++----- 1 file changed, 110 insertions(+), 14 deletions(-) diff --git a/storage_file/i18n/it.po b/storage_file/i18n/it.po index 495070c79c..31b90b9997 100644 --- a/storage_file/i18n/it.po +++ b/storage_file/i18n/it.po @@ -16,6 +16,12 @@ msgstr "" "Plural-Forms: nplurals=2; plural=n != 1;\n" "X-Generator: Weblate 5.6.2\n" +#. module: storage_file +#. odoo-python +#: code:addons/storage_file/models/storage_file.py:0 +msgid "A destination storage is required." +msgstr "" + #. module: storage_file #: model:ir.model.fields,help:storage_file.field_storage_file__url_path msgid "Accessible path, no base URL" @@ -26,6 +32,14 @@ msgstr "Percorso accessibile, non URL base" msgid "Active" msgstr "Attivo" +#. module: storage_file +#. odoo-python +#: code:addons/storage_file/wizards/swap_backend.py:0 +msgid "" +"All selected records must belong to the same source storage backend. Found: " +"%s" +msgstr "" + #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_backend__backend_view_use_internal_url msgid "Backend View Use Internal Url" @@ -46,6 +60,11 @@ msgstr "URL base" msgid "Base Url For Files" msgstr "URL base per i file" +#. module: storage_file +#: model_terms:ir.ui.view,arch_db:storage_file.storage_file_swap_backend_view_form +msgid "Cancel" +msgstr "" + #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_file__checksum msgid "Checksum/SHA1" @@ -64,12 +83,14 @@ msgstr "Azienda" #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_file__create_uid #: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__create_uid +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__create_uid msgid "Created by" msgstr "Creato da" #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_file__create_date #: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__create_date +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__create_date msgid "Created on" msgstr "Creato il" @@ -84,8 +105,8 @@ msgstr "Dati" #: model:ir.model.fields,help:storage_file.field_storage_backend__backend_view_use_internal_url msgid "" "Decide if Odoo backend views should use the external URL (usually a CDN) or " -"the internal url with direct access to the storage. This could save you some" -" money if you pay by CDN traffic." +"the internal url with direct access to the storage. This could save you some " +"money if you pay by CDN traffic." msgstr "" "Decidere se le viste backend Odoo devono usare l'URL esterno (normalmente un " "CDN) o l'URL interno con accesso diretto al deposito. Questo può far " @@ -95,19 +116,33 @@ msgstr "" #: model:ir.model.fields,help:storage_file.field_storage_backend__is_public msgid "" "Define if every files stored into this backend are public or not. Examples:\n" -"Private: your file/image can not be displayed is the user is not logged (not available on other website);\n" -"Public: your file/image can be displayed if nobody is logged (useful to display files on external websites)" +"Private: your file/image can not be displayed is the user is not logged (not " +"available on other website);\n" +"Public: your file/image can be displayed if nobody is logged (useful to " +"display files on external websites)" msgstr "" -"Definisce se ogni file archiviato in questo bcackend è pubblico o no. Esempi:" -"\n" +"Definisce se ogni file archiviato in questo bcackend è pubblico o no. " +"Esempi:\n" "Privato: il file/immagine non può essere visualizzato se l'utente non ha " "effettuato l'accesso (non disponibile nel sito web);\n" "Pubblico: il file/immagine può essere visualizzato se nessuno ha effettuato " "l'accesso (utile per visualizzare i file in siti web esterni)" +#. module: storage_file +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__dest_backend_id +msgid "Destination Storage" +msgstr "" + +#. module: storage_file +#. odoo-python +#: code:addons/storage_file/wizards/swap_backend.py:0 +msgid "Destination storage must differ from source." +msgstr "" + #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_file__display_name #: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__display_name +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__display_name msgid "Display Name" msgstr "Nome visualizzato" @@ -161,6 +196,16 @@ msgstr "Strategia nome file" msgid "Filename without extension" msgstr "Nome file senza estensione" +#. module: storage_file +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__file_ids +msgid "Files" +msgstr "" + +#. module: storage_file +#: model_terms:ir.ui.view,arch_db:storage_file.storage_file_swap_backend_view_form +msgid "Files to swap" +msgstr "" + #. module: storage_file #: model:ir.model.fields,help:storage_file.field_storage_file__internal_url msgid "HTTP URL to load the file directly from storage." @@ -179,6 +224,7 @@ msgstr "Dimensione file umana" #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_file__id #: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__id +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__id msgid "ID" msgstr "ID" @@ -204,12 +250,14 @@ msgstr "È pubblico" #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_file__write_uid #: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__write_uid +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__write_uid msgid "Last Updated by" msgstr "Ultimo aggiornamento di" #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_file__write_date #: model:ir.model.fields,field_description:storage_file.field_storage_file_replace__write_date +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__write_date msgid "Last Updated on" msgstr "Ultimo aggiornamento il" @@ -231,8 +279,8 @@ msgstr "Nome e ID" #. module: storage_file #: model:ir.model.fields,help:storage_file.field_storage_backend__url_include_directory_path msgid "" -"Normally the directory_path it's for internal usage. If this flag is enabled" -" the path will be used to compute the public URL." +"Normally the directory_path it's for internal usage. If this flag is enabled " +"the path will be used to compute the public URL." msgstr "" "Normalmente il directory_path è per uso interno. Se questa opzione è " "abilitata il percorso verrà utilizzato per calcolare l'URL pubblico." @@ -242,6 +290,18 @@ msgstr "" msgid "Odoo" msgstr "Odoo" +#. module: storage_file +#. odoo-python +#: code:addons/storage_file/wizards/swap_backend.py:0 +msgid "Please select a destination storage." +msgstr "" + +#. module: storage_file +#. odoo-python +#: code:addons/storage_file/wizards/swap_backend.py:0 +msgid "Please select at least one file." +msgstr "" + #. module: storage_file #: model_terms:ir.ui.view,arch_db:storage_file.storage_backend_view_form msgid "Recompute base URL for files" @@ -276,7 +336,8 @@ msgstr "Fornito da" #: model_terms:ir.ui.view,arch_db:storage_file.storage_backend_view_form msgid "" "Served by Odoo option will use `web.base.url` as the base URL.\n" -"
    Make sure this parameter is properly configured and accessible\n" +"
    Make sure this parameter is properly configured and " +"accessible\n" " from everwhere you want to access the service." msgstr "" "Fornito dall'opzione Odoo userà `web.base.url` come URL base.\n" @@ -294,6 +355,11 @@ msgstr "Frazione" msgid "Slug-ified name with ID for URL" msgstr "Nome frazionato con ID per URL" +#. module: storage_file +#: model:ir.model.fields,field_description:storage_file.field_storage_file_swap_backend__source_backend_id +msgid "Source Storage" +msgstr "" + #. module: storage_file #: model:ir.model.fields,field_description:storage_file.field_storage_file__backend_id msgid "Storage" @@ -314,13 +380,31 @@ msgstr "File deposito" msgid "" "Strategy to build the name of the file to be stored.\n" "Name and ID: will store the file with its name + its id.\n" -"SHA Hash: will use the hash of the file as filename (same method as the native attachment storage)" +"SHA Hash: will use the hash of the file as filename (same method as the " +"native attachment storage)" msgstr "" "Strategia per costruire il nome del file da archiviare.\n" "Nome e ID: archivierà il file con nome + il suo ID\n" "Hash SHA: utilizzerà l'hash del file come nome del file (stesso metodo del " "deposito allegati nativo)" +#. module: storage_file +#: model_terms:ir.ui.view,arch_db:storage_file.storage_file_swap_backend_view_form +msgid "Swap Files" +msgstr "" + +#. module: storage_file +#: model:ir.actions.act_window,name:storage_file.storage_file_swap_backend_action +#: model:ir.actions.server,name:storage_file.storage_file_swap_backend_server_action +#: model_terms:ir.ui.view,arch_db:storage_file.storage_file_swap_backend_view_form +msgid "Swap Storage Backend" +msgstr "" + +#. module: storage_file +#: model:ir.model,name:storage_file.model_storage_file_swap_backend +msgid "Swap storage files between backends" +msgstr "" + #. module: storage_file #. odoo-python #: code:addons/storage_file/models/storage_file.py:0 @@ -331,6 +415,14 @@ msgstr "" "La strategia del nome file è vuota per il backend %s.\n" "Configurarlo" +#. module: storage_file +#. odoo-python +#: code:addons/storage_file/models/storage_file.py:0 +msgid "" +"The filename strategy is empty for the backend %s.\n" +"Please configure it." +msgstr "" + #. module: storage_file #: model:ir.model.constraint,message:storage_file.constraint_storage_file_path_uniq msgid "The private path must be uniq per backend" @@ -359,11 +451,15 @@ msgstr "Percorso URL" #. module: storage_file #: model_terms:ir.ui.view,arch_db:storage_file.storage_backend_view_form msgid "" -"When served by external service you might have special environment configuration\n" +"When served by external service you might have special environment " +"configuration\n" " for building final files URLs.\n" -"
    For performance reasons, the base URL is computed and stored.\n" -" If you change some parameters (eg: in local dev environment or special instances)\n" -" and you still want to see the images you might need to refresh this URL\n" +"
    For performance reasons, the base URL is computed " +"and stored.\n" +" If you change some parameters (eg: in local dev " +"environment or special instances)\n" +" and you still want to see the images you might need to " +"refresh this URL\n" " to make sure images and/or files are loaded correctly." msgstr "" "Quando fornito da servizio esterno serve avere una configurazione ambiente "