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_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): 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 77bee313bf..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)", @@ -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/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 " 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" diff --git a/storage_file/models/storage_file.py b/storage_file/models/storage_file.py index 2617e5fac6..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") @@ -220,6 +233,91 @@ 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, + - 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.")) + 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, + ) + ) + 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 + try: + if not old_relative_path: + 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) + # 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.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: + _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: + 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, + ) + return {"moved": moved, "failed": failed} + @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/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/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..5575be4b77 --- /dev/null +++ b/storage_file/tests/test_swap_backend.py @@ -0,0 +1,206 @@ +# 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") + + 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") + self.assertEqual(base64.b64decode(stfile.data), b"payload") + self.assertIn(stfile.name, result["moved"][0]) + + 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) + 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: + 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() + 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_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( + type(self.env["storage.backend"]), + "add", + side_effect=RuntimeError("boom"), + ): + 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" + ) + + def test_swap_swallows_old_backend_delete_error(self): + stfile = self._create_storage_file(data=b"payload") + with mock.patch.object( + type(self.env["storage.backend"]), + "delete", + side_effect=RuntimeError("boom"), + ): + with self.assertLogs( + "odoo.addons.storage_file.models.storage_file", level="WARNING" + ) as log_cm: + 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) + ) + # File still counts as moved + self.assertEqual(len(result["moved"]), 1) + + # -- 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.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 + + # -- 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) 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..6cb36245c8 --- /dev/null +++ b/storage_file/wizards/swap_backend.py @@ -0,0 +1,83 @@ +# 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._action_apply() + return {"type": "ir.actions.act_window_close"} + + def _action_apply(self): + self.file_ids._swap_backend(self.dest_backend_id) 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") + +
diff --git a/storage_file_swap_backend_queue/README.rst b/storage_file_swap_backend_queue/README.rst new file mode 100644 index 0000000000..c189e464ac --- /dev/null +++ b/storage_file_swap_backend_queue/README.rst @@ -0,0 +1,104 @@ +.. 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/__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..326172c51a --- /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.1.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/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 "" 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/static/description/icon.png b/storage_file_swap_backend_queue/static/description/icon.png new file mode 100644 index 0000000000..1dcc49c24f Binary files /dev/null and b/storage_file_swap_backend_queue/static/description/icon.png differ diff --git a/storage_file_swap_backend_queue/static/description/index.html b/storage_file_swap_backend_queue/static/description/index.html new file mode 100644 index 0000000000..3f39671c7c --- /dev/null +++ b/storage_file_swap_backend_queue/static/description/index.html @@ -0,0 +1,441 @@ + + + + + +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_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() 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 cf2c755a1d..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)", @@ -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/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" 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" 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/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_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, +} + + + diff --git a/storage_media/__manifest__.py b/storage_media/__manifest__.py index 6d93d86439..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)", @@ -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/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" 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" 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, +} + + + 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