From 40192ac1f93b6ba28b0438cbfb020b2f098782a7 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 11 Jun 2026 09:31:26 +0200 Subject: [PATCH 1/5] [IMP] storage_backend: add storage category A way to classify storages in a functional way. For instance: you might have N storage backends for the shop, N storage backends for data import, N storage backends for EDI purposes. With this change you can finally organize them and let your customers understand what such storages do. --- storage_backend/__manifest__.py | 1 + storage_backend/models/__init__.py | 1 + storage_backend/models/storage_backend.py | 6 +++ .../models/storage_backend_category.py | 14 +++++ storage_backend/security/ir.model.access.csv | 1 + .../views/backend_storage_view.xml | 8 +++ .../views/storage_backend_category_view.xml | 54 +++++++++++++++++++ 7 files changed, 85 insertions(+) create mode 100644 storage_backend/models/storage_backend_category.py create mode 100644 storage_backend/views/storage_backend_category_view.xml diff --git a/storage_backend/__manifest__.py b/storage_backend/__manifest__.py index 3959cdbe63..f889c6b115 100644 --- a/storage_backend/__manifest__.py +++ b/storage_backend/__manifest__.py @@ -15,6 +15,7 @@ "depends": ["base", "component", "server_environment"], "data": [ "views/backend_storage_view.xml", + "views/storage_backend_category_view.xml", "data/data.xml", "security/ir.model.access.csv", ], diff --git a/storage_backend/models/__init__.py b/storage_backend/models/__init__.py index f45f402268..4a96db5b72 100644 --- a/storage_backend/models/__init__.py +++ b/storage_backend/models/__init__.py @@ -1 +1,2 @@ from . import storage_backend +from . import storage_backend_category diff --git a/storage_backend/models/storage_backend.py b/storage_backend/models/storage_backend.py index 2c4e5af464..bb8541d510 100644 --- a/storage_backend/models/storage_backend.py +++ b/storage_backend/models/storage_backend.py @@ -60,6 +60,12 @@ class StorageBackend(models.Model): _description = "Storage Backend" name = fields.Char(required=True) + categ_id = fields.Many2one( + "storage.backend.category", + string="Category", + ondelete="restrict", + help="Category to group backends for swapping operations", + ) backend_type = fields.Selection( selection=[("filesystem", "Filesystem")], required=True, default="filesystem" ) diff --git a/storage_backend/models/storage_backend_category.py b/storage_backend/models/storage_backend_category.py new file mode 100644 index 0000000000..a4e898435b --- /dev/null +++ b/storage_backend/models/storage_backend_category.py @@ -0,0 +1,14 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import fields, models + + +class StorageBackendCategory(models.Model): + _name = "storage.backend.category" + _description = "Storage Backend Category" + _order = "name" + + name = fields.Char(required=True, index=True) + description = fields.Text() + backend_ids = fields.One2many("storage.backend", "categ_id", string="Backends") diff --git a/storage_backend/security/ir.model.access.csv b/storage_backend/security/ir.model.access.csv index dd245d4814..1dbee96e50 100644 --- a/storage_backend/security/ir.model.access.csv +++ b/storage_backend/security/ir.model.access.csv @@ -1,2 +1,3 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_storage_backend_edit,storage_backend edit,model_storage_backend,base.group_system,1,1,1,1 +access_storage_backend_category_edit,storage_backend_category edit,model_storage_backend_category,base.group_system,1,1,1,1 diff --git a/storage_backend/views/backend_storage_view.xml b/storage_backend/views/backend_storage_view.xml index 5c9735111a..339bf9bf53 100644 --- a/storage_backend/views/backend_storage_view.xml +++ b/storage_backend/views/backend_storage_view.xml @@ -5,6 +5,7 @@ + @@ -30,6 +31,7 @@ + @@ -42,6 +44,12 @@ + + diff --git a/storage_backend/views/storage_backend_category_view.xml b/storage_backend/views/storage_backend_category_view.xml new file mode 100644 index 0000000000..b85bb63f4e --- /dev/null +++ b/storage_backend/views/storage_backend_category_view.xml @@ -0,0 +1,54 @@ + + + + storage.backend.category + + + + + + + + + storage.backend.category + +
+ + + + + + + + + + + +
+
+
+ + + storage.backend.category + + + + + + + + + Storage Backend Category + ir.actions.act_window + storage.backend.category + list,form + + + + +
From 1355c9dd505b9f512f2c9d6e89589ad3cd408fd6 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 11 Jun 2026 09:38:27 +0200 Subject: [PATCH 2/5] [IMP] storage_file: limit swap to selected backends Is not allowed to swap files to a backend of a different category. A technical door is left opened in case for specific reasons you want force file swap by using the ctx key swap_backend_bypass_category_check. --- storage_file/models/storage_file.py | 12 ++ storage_file/tests/test_swap_backend.py | 140 ++++++++++++++++++++++++ storage_file/wizards/swap_backend.py | 16 ++- storage_file/wizards/swap_backend.xml | 21 +++- 4 files changed, 185 insertions(+), 4 deletions(-) diff --git a/storage_file/models/storage_file.py b/storage_file/models/storage_file.py index b76c0d05e5..7e08666199 100644 --- a/storage_file/models/storage_file.py +++ b/storage_file/models/storage_file.py @@ -293,6 +293,18 @@ def _swap_backend(self, new_backend): new_backend.name, ) ) + if not self.env.context.get("swap_backend_bypass_category_check"): + for record in self.sudo(): + if not record.exists() or record.backend_id == new_backend: + continue + if record.backend_id.categ_id != new_backend.categ_id: + raise UserError( + self.env._( + "Destination backend category must match source backend " + "category for %s.", + record.name, + ) + ) moved = [] failed = [] for record in self.sudo(): diff --git a/storage_file/tests/test_swap_backend.py b/storage_file/tests/test_swap_backend.py index 5575be4b77..faa99f80ba 100644 --- a/storage_file/tests/test_swap_backend.py +++ b/storage_file/tests/test_swap_backend.py @@ -74,6 +74,33 @@ 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_rejects_different_backend_category(self): + src_categ = self.env["storage.backend.category"].create({"name": "SRC"}) + dst_categ = self.env["storage.backend.category"].create({"name": "DST"}) + self.backend_a.categ_id = src_categ + self.backend_b.categ_id = dst_categ + stfile = self._create_storage_file(backend=self.backend_a) + + with self.assertRaisesRegex( + UserError, "Destination backend category must match source backend category" + ): + stfile._swap_backend(self.backend_b) + + def test_swap_allows_different_backend_category_with_bypass(self): + src_categ = self.env["storage.backend.category"].create({"name": "SRC"}) + dst_categ = self.env["storage.backend.category"].create({"name": "DST"}) + self.backend_a.categ_id = src_categ + self.backend_b.categ_id = dst_categ + stfile = self._create_storage_file(backend=self.backend_a, data=b"payload") + + result = stfile.with_context( + swap_backend_bypass_category_check=True + )._swap_backend(self.backend_b) + + self.assertEqual(stfile.backend_id, self.backend_b) + self.assertEqual(base64.b64decode(stfile.data), b"payload") + self.assertIn(stfile.name, result["moved"][0]) + 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") @@ -204,3 +231,116 @@ def test_write_backend_id_noop_if_same(self): stfile.backend_id = self.backend_a self.assertEqual(stfile.backend_id, self.backend_a) self.assertEqual(stfile.relative_path, old_path) + + # -- category-based filtering ---------------------------------------- + + def test_wizard_computes_allowed_backends_by_category(self): + """Allowed destination backends are filtered by source backend category.""" + # Create categories + categ = self.env["storage.backend.category"].create({"name": "Group A"}) + categ2 = self.env["storage.backend.category"].create({"name": "Group B"}) + + # Create backends in different categories + backend_a_cat = self.backend_a.copy( + { + "name": "Backend A (Group A)", + "categ_id": categ.id, + "directory_path": "a_cat", + } + ) + backend_b_cat = self.backend_b.copy( + { + "name": "Backend B (Group A)", + "categ_id": categ.id, + "directory_path": "b_cat", + } + ) + backend_other = self.backend_a.copy( + { + "name": "Backend (Group B)", + "categ_id": categ2.id, + "directory_path": "other", + } + ) + + # Create wizard + wizard = self.env["storage.file.swap.backend"].create( + { + "source_backend_id": backend_a_cat.id, + "file_ids": [(6, 0, [])], + } + ) + + # Compute should filter to same category + self.assertIn(backend_b_cat, wizard.allowed_dest_backend_ids) + self.assertNotIn(backend_other, wizard.allowed_dest_backend_ids) + self.assertNotIn(backend_a_cat, wizard.allowed_dest_backend_ids) + + def test_wizard_allows_all_when_source_has_no_category(self): + """Wizard allows all backends when source has no category.""" + backend_cat = self.backend_b.copy( + { + "name": "Backend (Cat)", + "categ_id": self.env["storage.backend.category"] + .create({"name": "Cat"}) + .id, + "directory_path": "cat", + } + ) + + # Create wizard with uncategorized source backend + wizard = self.env["storage.file.swap.backend"].create( + { + "source_backend_id": self.backend_a.id, + "file_ids": [(6, 0, [])], + } + ) + + # All backends except source should be available + self.assertIn(self.backend_b, wizard.allowed_dest_backend_ids) + self.assertIn(backend_cat, wizard.allowed_dest_backend_ids) + self.assertNotIn(self.backend_a, wizard.allowed_dest_backend_ids) + + def test_wizard_form_respects_allowed_backends_domain(self): + """Form domain correctly restricts destination to allowed backends.""" + # Create category and categorized backends + categ = self.env["storage.backend.category"].create({"name": "Group A"}) + backend_a_cat = self.backend_a.copy( + { + "name": "Backend A (Group A)", + "categ_id": categ.id, + "directory_path": "a_cat", + } + ) + backend_b_cat = self.backend_b.copy( + { + "name": "Backend B (Group A)", + "categ_id": categ.id, + "directory_path": "b_cat", + } + ) + + stfile = self._create_storage_file(backend=backend_a_cat) + + # Test Form creation with proper category-based filtering + with Form( + self.env["storage.file.swap.backend"].with_context( + active_model="storage.file", + active_ids=stfile.ids, + ) + ) as wiz_form: + # Source should be set by default_get + self.assertEqual(wiz_form.source_backend_id, backend_a_cat) + # Should only be able to select backend_b_cat (same category) + allowed = wiz_form.allowed_dest_backend_ids + self.assertIn(backend_b_cat, allowed) + self.assertNotIn(backend_a_cat, allowed) + # Should be able to set destination + wiz_form.dest_backend_id = backend_b_cat + + # Verify wizard was created with correct values + wizard = self.env["storage.file.swap.backend"].search( + [], order="id desc", limit=1 + ) + self.assertEqual(wizard.source_backend_id, backend_a_cat) + self.assertEqual(wizard.dest_backend_id, backend_b_cat) diff --git a/storage_file/wizards/swap_backend.py b/storage_file/wizards/swap_backend.py index 6cb36245c8..18981ee7cc 100644 --- a/storage_file/wizards/swap_backend.py +++ b/storage_file/wizards/swap_backend.py @@ -19,10 +19,14 @@ class StorageFileSwapBackend(models.TransientModel): string="Source Storage", readonly=True, ) + allowed_dest_backend_ids = fields.Many2many( + "storage.backend", + string="Allowed Destination Backends", + compute="_compute_allowed_dest_backend_ids", + ) dest_backend_id = fields.Many2one( "storage.backend", string="Destination Storage", - domain="[('id', '!=', source_backend_id)]", ) file_ids = fields.Many2many( "storage.file", @@ -30,6 +34,16 @@ class StorageFileSwapBackend(models.TransientModel): domain="[('backend_id', '=', source_backend_id)]", ) + @api.depends("source_backend_id") + def _compute_allowed_dest_backend_ids(self): + """Compute available destination backends based on source backend category.""" + for wizard in self: + domain = [("id", "!=", wizard.source_backend_id.id)] + if wizard.source_backend_id.categ_id: + # If source has a category, restrict to same category + domain.append(("categ_id", "=", wizard.source_backend_id.categ_id.id)) + wizard.allowed_dest_backend_ids = self.env["storage.backend"].search(domain) + @api.model def default_get(self, fields_list): res = super().default_get(fields_list) diff --git a/storage_file/wizards/swap_backend.xml b/storage_file/wizards/swap_backend.xml index 5aac2f5e81..5276966fa1 100644 --- a/storage_file/wizards/swap_backend.xml +++ b/storage_file/wizards/swap_backend.xml @@ -10,11 +10,26 @@
- - + + + - + From 422cb90f0d165df741bf032c032b974535332890 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 12 Jun 2026 09:20:33 +0200 Subject: [PATCH 3/5] fixup! [IMP] storage_file: limit swap to selected backends --- storage_file/models/storage_file.py | 6 ++ storage_file/tests/test_swap_backend.py | 97 +++++------------------- storage_file/views/storage_file_view.xml | 6 +- storage_file/wizards/swap_backend.py | 19 ++--- storage_file/wizards/swap_backend.xml | 4 +- 5 files changed, 38 insertions(+), 94 deletions(-) diff --git a/storage_file/models/storage_file.py b/storage_file/models/storage_file.py index 7e08666199..534abaebb4 100644 --- a/storage_file/models/storage_file.py +++ b/storage_file/models/storage_file.py @@ -36,6 +36,12 @@ class StorageFile(models.Model): required=True, default=lambda self: self._get_default_backend_id(), ) + backend_categ_id = fields.Many2one( + "storage.backend.category", + related="backend_id.categ_id", + string="Backend Category", + readonly=True, + ) url = fields.Char(compute="_compute_url", help="HTTP accessible path to the file") url_path = fields.Char( compute="_compute_url_path", help="Accessible path, no base URL" diff --git a/storage_file/tests/test_swap_backend.py b/storage_file/tests/test_swap_backend.py index faa99f80ba..16b237e9db 100644 --- a/storage_file/tests/test_swap_backend.py +++ b/storage_file/tests/test_swap_backend.py @@ -5,6 +5,8 @@ import base64 from unittest import mock +from lxml import etree + from odoo.exceptions import UserError from odoo.tests import Form @@ -234,13 +236,10 @@ def test_write_backend_id_noop_if_same(self): # -- category-based filtering ---------------------------------------- - def test_wizard_computes_allowed_backends_by_category(self): - """Allowed destination backends are filtered by source backend category.""" - # Create categories + def test_wizard_form_same_category_shown_as_dest(self): + """Wizard declares and applies a same-category destination domain.""" categ = self.env["storage.backend.category"].create({"name": "Group A"}) categ2 = self.env["storage.backend.category"].create({"name": "Group B"}) - - # Create backends in different categories backend_a_cat = self.backend_a.copy( { "name": "Backend A (Group A)", @@ -262,85 +261,29 @@ def test_wizard_computes_allowed_backends_by_category(self): "directory_path": "other", } ) - - # Create wizard - wizard = self.env["storage.file.swap.backend"].create( - { - "source_backend_id": backend_a_cat.id, - "file_ids": [(6, 0, [])], - } - ) - - # Compute should filter to same category - self.assertIn(backend_b_cat, wizard.allowed_dest_backend_ids) - self.assertNotIn(backend_other, wizard.allowed_dest_backend_ids) - self.assertNotIn(backend_a_cat, wizard.allowed_dest_backend_ids) - - def test_wizard_allows_all_when_source_has_no_category(self): - """Wizard allows all backends when source has no category.""" - backend_cat = self.backend_b.copy( - { - "name": "Backend (Cat)", - "categ_id": self.env["storage.backend.category"] - .create({"name": "Cat"}) - .id, - "directory_path": "cat", - } - ) - - # Create wizard with uncategorized source backend - wizard = self.env["storage.file.swap.backend"].create( - { - "source_backend_id": self.backend_a.id, - "file_ids": [(6, 0, [])], - } - ) - - # All backends except source should be available - self.assertIn(self.backend_b, wizard.allowed_dest_backend_ids) - self.assertIn(backend_cat, wizard.allowed_dest_backend_ids) - self.assertNotIn(self.backend_a, wizard.allowed_dest_backend_ids) - - def test_wizard_form_respects_allowed_backends_domain(self): - """Form domain correctly restricts destination to allowed backends.""" - # Create category and categorized backends - categ = self.env["storage.backend.category"].create({"name": "Group A"}) - backend_a_cat = self.backend_a.copy( - { - "name": "Backend A (Group A)", - "categ_id": categ.id, - "directory_path": "a_cat", - } - ) - backend_b_cat = self.backend_b.copy( - { - "name": "Backend B (Group A)", - "categ_id": categ.id, - "directory_path": "b_cat", - } - ) - stfile = self._create_storage_file(backend=backend_a_cat) - # Test Form creation with proper category-based filtering + # Assert the actual domain declared in the form view arch. + view = self.env.ref("storage_file.storage_file_swap_backend_view_form") + xml = etree.fromstring(view.arch_db.encode()) + dest_field = xml.xpath("//field[@name='dest_backend_id']") + self.assertEqual(len(dest_field), 1) + domain = dest_field[0].get("domain") + self.assertIn("('id', '!=', source_backend_id)", domain) + self.assertIn("('categ_id', '=', source_backend_categ_id)", domain) + self.assertNotIn("source_backend_id.categ_id", domain) + with Form( self.env["storage.file.swap.backend"].with_context( active_model="storage.file", active_ids=stfile.ids, ) ) as wiz_form: - # Source should be set by default_get self.assertEqual(wiz_form.source_backend_id, backend_a_cat) - # Should only be able to select backend_b_cat (same category) - allowed = wiz_form.allowed_dest_backend_ids - self.assertIn(backend_b_cat, allowed) - self.assertNotIn(backend_a_cat, allowed) - # Should be able to set destination + # domain: categ_id = Group A → Group B backend excluded + same_categ_backends = self.env["storage.backend"].search( + [("categ_id", "=", categ.id), ("id", "!=", backend_a_cat.id)] + ) + self.assertIn(backend_b_cat, same_categ_backends) + self.assertNotIn(backend_other, same_categ_backends) wiz_form.dest_backend_id = backend_b_cat - - # Verify wizard was created with correct values - wizard = self.env["storage.file.swap.backend"].search( - [], order="id desc", limit=1 - ) - self.assertEqual(wizard.source_backend_id, backend_a_cat) - self.assertEqual(wizard.dest_backend_id, backend_b_cat) diff --git a/storage_file/views/storage_file_view.xml b/storage_file/views/storage_file_view.xml index a922416d1d..acc313b06f 100644 --- a/storage_file/views/storage_file_view.xml +++ b/storage_file/views/storage_file_view.xml @@ -23,7 +23,11 @@ - + + diff --git a/storage_file/wizards/swap_backend.py b/storage_file/wizards/swap_backend.py index 18981ee7cc..fe82cb5eda 100644 --- a/storage_file/wizards/swap_backend.py +++ b/storage_file/wizards/swap_backend.py @@ -19,10 +19,11 @@ class StorageFileSwapBackend(models.TransientModel): string="Source Storage", readonly=True, ) - allowed_dest_backend_ids = fields.Many2many( - "storage.backend", - string="Allowed Destination Backends", - compute="_compute_allowed_dest_backend_ids", + source_backend_categ_id = fields.Many2one( + "storage.backend.category", + related="source_backend_id.categ_id", + string="Source Backend Category", + readonly=True, ) dest_backend_id = fields.Many2one( "storage.backend", @@ -34,16 +35,6 @@ class StorageFileSwapBackend(models.TransientModel): domain="[('backend_id', '=', source_backend_id)]", ) - @api.depends("source_backend_id") - def _compute_allowed_dest_backend_ids(self): - """Compute available destination backends based on source backend category.""" - for wizard in self: - domain = [("id", "!=", wizard.source_backend_id.id)] - if wizard.source_backend_id.categ_id: - # If source has a category, restrict to same category - domain.append(("categ_id", "=", wizard.source_backend_id.categ_id.id)) - wizard.allowed_dest_backend_ids = self.env["storage.backend"].search(domain) - @api.model def default_get(self, fields_list): res = super().default_get(fields_list) diff --git a/storage_file/wizards/swap_backend.xml b/storage_file/wizards/swap_backend.xml index 5276966fa1..66c5e0a11b 100644 --- a/storage_file/wizards/swap_backend.xml +++ b/storage_file/wizards/swap_backend.xml @@ -15,11 +15,11 @@ readonly="1" options="{'no_open': True, 'no_create': True}" /> - + From 10f7d09243d727c7c883a5ee712dc5aff3e9e698 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 12 Jun 2026 09:21:30 +0200 Subject: [PATCH 4/5] [IMP] storage_image: form select only same category backend --- storage_image/views/storage_image.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/storage_image/views/storage_image.xml b/storage_image/views/storage_image.xml index 42338506e2..be5c875edd 100644 --- a/storage_image/views/storage_image.xml +++ b/storage_image/views/storage_image.xml @@ -40,7 +40,11 @@ - + + From 0cb1fd255ff8cd6ee162fae34f99c3db41fc77b3 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 12 Jun 2026 09:21:58 +0200 Subject: [PATCH 5/5] [IMP] storage_media: form select only same category backend --- storage_media/views/storage_media_view.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/storage_media/views/storage_media_view.xml b/storage_media/views/storage_media_view.xml index 20ba7494c6..0f84abd25f 100644 --- a/storage_media/views/storage_media_view.xml +++ b/storage_media/views/storage_media_view.xml @@ -35,7 +35,11 @@ - + +