Skip to content

Commit 9214a49

Browse files
committed
Merge PR #633 into 18.0
Signed-off-by simahawk
2 parents e0430c1 + 7e3ca26 commit 9214a49

14 files changed

Lines changed: 224 additions & 7 deletions

File tree

storage_backend/__manifest__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"depends": ["base", "component", "server_environment"],
1616
"data": [
1717
"views/backend_storage_view.xml",
18+
"views/storage_backend_category_view.xml",
1819
"data/data.xml",
1920
"security/ir.model.access.csv",
2021
],

storage_backend/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
from . import storage_backend
2+
from . import storage_backend_category

storage_backend/models/storage_backend.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ class StorageBackend(models.Model):
6060
_description = "Storage Backend"
6161

6262
name = fields.Char(required=True)
63+
categ_id = fields.Many2one(
64+
"storage.backend.category",
65+
string="Category",
66+
ondelete="restrict",
67+
help="Category to group backends for swapping operations",
68+
)
6369
backend_type = fields.Selection(
6470
selection=[("filesystem", "Filesystem")], required=True, default="filesystem"
6571
)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Copyright 2026 Camptocamp SA
2+
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
3+
4+
from odoo import fields, models
5+
6+
7+
class StorageBackendCategory(models.Model):
8+
_name = "storage.backend.category"
9+
_description = "Storage Backend Category"
10+
_order = "name"
11+
12+
name = fields.Char(required=True, index=True)
13+
description = fields.Text()
14+
backend_ids = fields.One2many("storage.backend", "categ_id", string="Backends")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
22
access_storage_backend_edit,storage_backend edit,model_storage_backend,base.group_system,1,1,1,1
3+
access_storage_backend_category_edit,storage_backend_category edit,model_storage_backend_category,base.group_system,1,1,1,1

storage_backend/views/backend_storage_view.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<field name="arch" type="xml">
66
<list>
77
<field name="name" />
8+
<field name="categ_id" />
89
<field name="backend_type" />
910
</list>
1011
</field>
@@ -30,6 +31,7 @@
3031
</h1>
3132
</div>
3233
<group name="config">
34+
<field name="categ_id" />
3335
<field name="backend_type" />
3436
<field name="directory_path" />
3537
</group>
@@ -42,6 +44,12 @@
4244
<field name="arch" type="xml">
4345
<search string="Storage Backend">
4446
<field name="name" />
47+
<separator />
48+
<filter
49+
string="Category"
50+
name="group_by_category"
51+
context="{'group_by': 'categ_id'}"
52+
/>
4553
</search>
4654
</field>
4755
</record>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?xml version="1.0" encoding="UTF-8" ?>
2+
<odoo>
3+
<record id="storage_backend_category_view_tree" model="ir.ui.view">
4+
<field name="model">storage.backend.category</field>
5+
<field name="arch" type="xml">
6+
<list>
7+
<field name="name" />
8+
</list>
9+
</field>
10+
</record>
11+
12+
<record id="storage_backend_category_view_form" model="ir.ui.view">
13+
<field name="model">storage.backend.category</field>
14+
<field name="arch" type="xml">
15+
<form string="Storage Backend Category">
16+
<group>
17+
<field name="name" />
18+
<field name="description" />
19+
</group>
20+
<group name="backends" string="Backends">
21+
<field name="backend_ids" readonly="True">
22+
<list>
23+
<field name="name" />
24+
</list>
25+
</field>
26+
</group>
27+
</form>
28+
</field>
29+
</record>
30+
31+
<record id="storage_backend_category_view_search" model="ir.ui.view">
32+
<field name="model">storage.backend.category</field>
33+
<field name="arch" type="xml">
34+
<search string="Storage Backend Category">
35+
<field name="name" />
36+
</search>
37+
</field>
38+
</record>
39+
40+
<record model="ir.actions.act_window" id="act_open_storage_backend_category_view">
41+
<field name="name">Storage Backend Category</field>
42+
<field name="type">ir.actions.act_window</field>
43+
<field name="res_model">storage.backend.category</field>
44+
<field name="view_mode">list,form</field>
45+
<field name="search_view_id" ref="storage_backend_category_view_search" />
46+
</record>
47+
48+
<menuitem
49+
id="menu_storage_backend_category"
50+
parent="menu_storage"
51+
sequence="5"
52+
action="act_open_storage_backend_category_view"
53+
/>
54+
</odoo>

storage_file/models/storage_file.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ class StorageFile(models.Model):
3636
required=True,
3737
default=lambda self: self._get_default_backend_id(),
3838
)
39+
backend_categ_id = fields.Many2one(
40+
"storage.backend.category",
41+
related="backend_id.categ_id",
42+
string="Backend Category",
43+
)
3944
url = fields.Char(compute="_compute_url", help="HTTP accessible path to the file")
4045
url_path = fields.Char(
4146
compute="_compute_url_path", help="Accessible path, no base URL"
@@ -293,6 +298,18 @@ def _swap_backend(self, new_backend):
293298
new_backend.name,
294299
)
295300
)
301+
if not self.env.context.get("swap_backend_bypass_category_check"):
302+
for record in self.sudo():
303+
if not record.exists() or record.backend_id == new_backend:
304+
continue
305+
if record.backend_id.categ_id != new_backend.categ_id:
306+
raise UserError(
307+
self.env._(
308+
"Destination backend category must match source backend "
309+
"category for %s.",
310+
record.name,
311+
)
312+
)
296313
moved = []
297314
failed = []
298315
for record in self.sudo():

storage_file/tests/test_swap_backend.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import base64
66
from unittest import mock
77

8+
from lxml import etree
9+
810
from odoo.exceptions import UserError
911
from odoo.tests import Form
1012

@@ -74,6 +76,33 @@ def test_swap_requires_destination_filename_strategy(self):
7476
with self.assertRaisesRegex(UserError, "The filename strategy is empty"):
7577
stfile._swap_backend(self.backend_b)
7678

79+
def test_swap_rejects_different_backend_category(self):
80+
src_categ = self.env["storage.backend.category"].create({"name": "SRC"})
81+
dst_categ = self.env["storage.backend.category"].create({"name": "DST"})
82+
self.backend_a.categ_id = src_categ
83+
self.backend_b.categ_id = dst_categ
84+
stfile = self._create_storage_file(backend=self.backend_a)
85+
86+
with self.assertRaisesRegex(
87+
UserError, "Destination backend category must match source backend category"
88+
):
89+
stfile._swap_backend(self.backend_b)
90+
91+
def test_swap_allows_different_backend_category_with_bypass(self):
92+
src_categ = self.env["storage.backend.category"].create({"name": "SRC"})
93+
dst_categ = self.env["storage.backend.category"].create({"name": "DST"})
94+
self.backend_a.categ_id = src_categ
95+
self.backend_b.categ_id = dst_categ
96+
stfile = self._create_storage_file(backend=self.backend_a, data=b"payload")
97+
98+
result = stfile.with_context(
99+
swap_backend_bypass_category_check=True
100+
)._swap_backend(self.backend_b)
101+
102+
self.assertEqual(stfile.backend_id, self.backend_b)
103+
self.assertEqual(base64.b64decode(stfile.data), b"payload")
104+
self.assertIn(stfile.name, result["moved"][0])
105+
77106
def test_swap_failure_reports_in_failed(self):
78107
"""Upload failure is caught and reported in the failed list."""
79108
stfile = self._create_storage_file(data=b"payload")
@@ -204,3 +233,57 @@ def test_write_backend_id_noop_if_same(self):
204233
stfile.backend_id = self.backend_a
205234
self.assertEqual(stfile.backend_id, self.backend_a)
206235
self.assertEqual(stfile.relative_path, old_path)
236+
237+
# -- category-based filtering ----------------------------------------
238+
239+
def test_wizard_form_same_category_shown_as_dest(self):
240+
"""Wizard declares and applies a same-category destination domain."""
241+
categ = self.env["storage.backend.category"].create({"name": "Group A"})
242+
categ2 = self.env["storage.backend.category"].create({"name": "Group B"})
243+
backend_a_cat = self.backend_a.copy(
244+
{
245+
"name": "Backend A (Group A)",
246+
"categ_id": categ.id,
247+
"directory_path": "a_cat",
248+
}
249+
)
250+
backend_b_cat = self.backend_b.copy(
251+
{
252+
"name": "Backend B (Group A)",
253+
"categ_id": categ.id,
254+
"directory_path": "b_cat",
255+
}
256+
)
257+
backend_other = self.backend_a.copy(
258+
{
259+
"name": "Backend (Group B)",
260+
"categ_id": categ2.id,
261+
"directory_path": "other",
262+
}
263+
)
264+
stfile = self._create_storage_file(backend=backend_a_cat)
265+
266+
# Assert the actual domain declared in the form view arch.
267+
view = self.env.ref("storage_file.storage_file_swap_backend_view_form")
268+
xml = etree.fromstring(view.arch_db.encode())
269+
dest_field = xml.xpath("//field[@name='dest_backend_id']")
270+
self.assertEqual(len(dest_field), 1)
271+
domain = dest_field[0].get("domain")
272+
self.assertIn("('id', '!=', source_backend_id)", domain)
273+
self.assertIn("('categ_id', '=', source_backend_categ_id)", domain)
274+
self.assertNotIn("source_backend_id.categ_id", domain)
275+
276+
with Form(
277+
self.env["storage.file.swap.backend"].with_context(
278+
active_model="storage.file",
279+
active_ids=stfile.ids,
280+
)
281+
) as wiz_form:
282+
self.assertEqual(wiz_form.source_backend_id, backend_a_cat)
283+
# domain: categ_id = Group A → Group B backend excluded
284+
same_categ_backends = self.env["storage.backend"].search(
285+
[("categ_id", "=", categ.id), ("id", "!=", backend_a_cat.id)]
286+
)
287+
self.assertIn(backend_b_cat, same_categ_backends)
288+
self.assertNotIn(backend_other, same_categ_backends)
289+
wiz_form.dest_backend_id = backend_b_cat

storage_file/views/storage_file_view.xml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@
2323
<field name="name" />
2424
</h1>
2525
<group>
26-
<field name="backend_id" readonly="True" />
26+
<field name="backend_categ_id" invisible="1" />
27+
<field
28+
name="backend_id"
29+
domain="[('categ_id', '=', backend_categ_id)]"
30+
/>
2731
<field name="data" readonly="True" />
2832
<field name="url" widget="url" />
2933
<field name="human_file_size" />

0 commit comments

Comments
 (0)