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 + + + + +
diff --git a/storage_file/models/storage_file.py b/storage_file/models/storage_file.py index b76c0d05e5..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" @@ -293,6 +299,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..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 @@ -74,6 +76,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 +233,57 @@ 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_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"}) + 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", + } + ) + stfile = self._create_storage_file(backend=backend_a_cat) + + # 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: + self.assertEqual(wiz_form.source_backend_id, backend_a_cat) + # 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 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 6cb36245c8..fe82cb5eda 100644 --- a/storage_file/wizards/swap_backend.py +++ b/storage_file/wizards/swap_backend.py @@ -19,10 +19,15 @@ class StorageFileSwapBackend(models.TransientModel): string="Source Storage", readonly=True, ) + 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", string="Destination Storage", - domain="[('id', '!=', source_backend_id)]", ) file_ids = fields.Many2many( "storage.file", diff --git a/storage_file/wizards/swap_backend.xml b/storage_file/wizards/swap_backend.xml index 5aac2f5e81..66c5e0a11b 100644 --- a/storage_file/wizards/swap_backend.xml +++ b/storage_file/wizards/swap_backend.xml @@ -10,11 +10,26 @@
- - + + + - + 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 @@ - + + 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 @@ - + +