Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions storage_backend/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
Expand Down
1 change: 1 addition & 0 deletions storage_backend/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import storage_backend
from . import storage_backend_category
6 changes: 6 additions & 0 deletions storage_backend/models/storage_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
14 changes: 14 additions & 0 deletions storage_backend/models/storage_backend_category.py
Original file line number Diff line number Diff line change
@@ -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")
1 change: 1 addition & 0 deletions storage_backend/security/ir.model.access.csv
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions storage_backend/views/backend_storage_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<field name="arch" type="xml">
<list>
<field name="name" />
<field name="categ_id" />
<field name="backend_type" />
</list>
</field>
Expand All @@ -30,6 +31,7 @@
</h1>
</div>
<group name="config">
<field name="categ_id" />
<field name="backend_type" />
<field name="directory_path" />
</group>
Expand All @@ -42,6 +44,12 @@
<field name="arch" type="xml">
<search string="Storage Backend">
<field name="name" />
<separator />
<filter
string="Category"
name="group_by_category"
context="{'group_by': 'categ_id'}"
/>
</search>
</field>
</record>
Expand Down
54 changes: 54 additions & 0 deletions storage_backend/views/storage_backend_category_view.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="storage_backend_category_view_tree" model="ir.ui.view">
<field name="model">storage.backend.category</field>
<field name="arch" type="xml">
<list>
<field name="name" />
</list>
</field>
</record>

<record id="storage_backend_category_view_form" model="ir.ui.view">
<field name="model">storage.backend.category</field>
<field name="arch" type="xml">
<form string="Storage Backend Category">
<group>
<field name="name" />
<field name="description" />
</group>
<group name="backends" string="Backends">
<field name="backend_ids" readonly="True">
<list>
<field name="name" />
</list>
</field>
</group>
</form>
</field>
</record>

<record id="storage_backend_category_view_search" model="ir.ui.view">
<field name="model">storage.backend.category</field>
<field name="arch" type="xml">
<search string="Storage Backend Category">
<field name="name" />
</search>
</field>
</record>

<record model="ir.actions.act_window" id="act_open_storage_backend_category_view">
<field name="name">Storage Backend Category</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">storage.backend.category</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="storage_backend_category_view_search" />
</record>

<menuitem
id="menu_storage_backend_category"
parent="menu_storage"
sequence="5"
action="act_open_storage_backend_category_view"
/>
</odoo>
18 changes: 18 additions & 0 deletions storage_file/models/storage_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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():
Expand Down
83 changes: 83 additions & 0 deletions storage_file/tests/test_swap_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import base64
from unittest import mock

from lxml import etree

from odoo.exceptions import UserError
from odoo.tests import Form

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
6 changes: 5 additions & 1 deletion storage_file/views/storage_file_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@
<field name="name" />
</h1>
<group>
<field name="backend_id" readonly="True" />
<field name="backend_categ_id" invisible="1" />
<field
name="backend_id"
domain="[('categ_id', '=', backend_categ_id)]"
/>
<field name="data" readonly="True" />
<field name="url" widget="url" />
<field name="human_file_size" />
Expand Down
7 changes: 6 additions & 1 deletion storage_file/wizards/swap_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
readonly=True,

It's default for related fields.

)
dest_backend_id = fields.Many2one(
"storage.backend",
string="Destination Storage",
domain="[('id', '!=', source_backend_id)]",
)
file_ids = fields.Many2many(
"storage.file",
Expand Down
21 changes: 18 additions & 3 deletions storage_file/wizards/swap_backend.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,26 @@
<form string="Swap Storage Backend">
<sheet>
<group>
<field name="source_backend_id" readonly="1" />
<field name="dest_backend_id" required="1" />
<field
name="source_backend_id"
readonly="1"
options="{'no_open': True, 'no_create': True}"
/>
<field name="source_backend_categ_id" invisible="1" />
<field
name="dest_backend_id"
required="1"
domain="[('id', '!=', source_backend_id), ('categ_id', '=', source_backend_categ_id)]"
options="{'no_open': True, 'no_create': True}"
/>
</group>
<group string="Files to swap" name="files">
<field name="file_ids" nolabel="1" required="1">
<field
name="file_ids"
nolabel="1"
required="1"
options="{'no_open': True, 'no_create': True}"
>
<list>
<field name="name" />
<field name="backend_id" />
Expand Down
6 changes: 5 additions & 1 deletion storage_image/views/storage_image.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@
<notebook>
<page name="info" string="File Informations">
<group name="info">
<field name="backend_id" />
<field name="backend_categ_id" invisible="1" />
<field
name="backend_id"
domain="[('categ_id', '=', backend_categ_id)]"
/>
<field name="file_id" readonly="True" required="False" />
<field name="thumb_medium_id" />
<field name="thumb_small_id" />
Expand Down
6 changes: 5 additions & 1 deletion storage_media/views/storage_media_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@
<field name="url" readonly="True" widget="url" />
<field name="name" />
<field name="media_type_id" />
<field name="backend_id" />
<field name="backend_categ_id" invisible="1" />
<field
name="backend_id"
domain="[('categ_id', '=', backend_categ_id)]"
/>
</group>
</sheet>
</form>
Expand Down
Loading