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 @@