From 3a8bc4122451ba7d3f65667434baca5581182b2d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 21 May 2026 13:26:27 +0200 Subject: [PATCH 01/13] [IMP] storage_file: add search by public/private --- storage_file/models/storage_file.py | 21 +++++++++ storage_file/tests/__init__.py | 1 + storage_file/tests/test_is_public.py | 59 ++++++++++++++++++++++++ storage_file/views/storage_file_view.xml | 13 ++++++ 4 files changed, 94 insertions(+) create mode 100644 storage_file/tests/test_is_public.py diff --git a/storage_file/models/storage_file.py b/storage_file/models/storage_file.py index f7f23614ec..12d05469ea 100644 --- a/storage_file/models/storage_file.py +++ b/storage_file/models/storage_file.py @@ -65,6 +65,27 @@ class StorageFile(models.Model): "res.company", "Company", default=lambda self: self.env.user.company_id.id ) file_type = fields.Selection([]) + is_public = fields.Boolean( + compute="_compute_is_public", + compute_sudo=True, + search="_search_is_public", + # Not stored to avoid massive recomputes when the backend flag changes. + help="Reflects the `is_public` flag of the related backend.", + ) + + @api.depends("backend_id.is_public") + def _compute_is_public(self): + for rec in self: + rec.is_public = rec.backend_id.is_public + + def _search_is_public(self, operator, value): + # Look up matching backends with sudo so that users with limited ACL + # on `storage.backend` can still filter their accessible files by + # public flag. + backends = ( + self.env["storage.backend"].sudo().search([("is_public", operator, value)]) + ) + return [("backend_id", "in", backends.ids)] _sql_constraints = [ ( diff --git a/storage_file/tests/__init__.py b/storage_file/tests/__init__.py index cdf4f78fa1..3aa707d6d8 100644 --- a/storage_file/tests/__init__.py +++ b/storage_file/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_storage_file from . import test_swap_backend +from . import test_is_public diff --git a/storage_file/tests/test_is_public.py b/storage_file/tests/test_is_public.py new file mode 100644 index 0000000000..7f93df7163 --- /dev/null +++ b/storage_file/tests/test_is_public.py @@ -0,0 +1,59 @@ +# Copyright 2026 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.addons.component.tests.common import TransactionComponentCase + + +class TestStorageFileIsPublic(TransactionComponentCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.backend = cls.env["storage.backend"].create( + {"name": "Test backend", "backend_type": "filesystem"} + ) + cls.storage_file = cls.env["storage.file"].create( + { + "name": "test-public.txt", + "backend_id": cls.backend.id, + "data": b"aGVsbG8=", # "hello" base64 + } + ) + + def test_reflects_backend_flag(self): + self.assertFalse(self.storage_file.is_public) + self.backend.is_public = True + self.assertTrue(self.storage_file.is_public) + + def test_search_true(self): + self.backend.is_public = True + result = self.env["storage.file"].search( + [("is_public", "=", True), ("id", "=", self.storage_file.id)] + ) + self.assertIn(self.storage_file, result) + + def test_search_false(self): + self.backend.is_public = False + result = self.env["storage.file"].search( + [("is_public", "=", False), ("id", "=", self.storage_file.id)] + ) + self.assertIn(self.storage_file, result) + + def test_search_with_two_backends(self): + public_backend = self.env["storage.backend"].create( + { + "name": "Public backend", + "backend_type": "filesystem", + "is_public": True, + } + ) + public_file = self.env["storage.file"].create( + { + "name": "public.txt", + "backend_id": public_backend.id, + "data": b"aGVsbG8=", + } + ) + result = self.env["storage.file"].search([("is_public", "=", True)]) + self.assertIn(public_file, result) + self.assertNotIn(self.storage_file, result) diff --git a/storage_file/views/storage_file_view.xml b/storage_file/views/storage_file_view.xml index 7405b367b8..200cbc9532 100644 --- a/storage_file/views/storage_file_view.xml +++ b/storage_file/views/storage_file_view.xml @@ -8,6 +8,7 @@ + @@ -29,6 +30,7 @@ + @@ -42,6 +44,17 @@ + + + From 363ad50b1fc2d273a47123b40ade09b8ced5774e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 21 May 2026 13:27:15 +0200 Subject: [PATCH 02/13] [IMP] storage_image: add search by public/private --- .../models/storage_image_relation_abstract.py | 1 + storage_image/tests/__init__.py | 1 + storage_image/tests/test_is_public.py | 25 +++++++++++++++++++ storage_image/views/storage_image.xml | 12 +++++++++ 4 files changed, 39 insertions(+) create mode 100644 storage_image/tests/test_is_public.py diff --git a/storage_image/models/storage_image_relation_abstract.py b/storage_image/models/storage_image_relation_abstract.py index 0c6272227f..838f625130 100644 --- a/storage_image/models/storage_image_relation_abstract.py +++ b/storage_image/models/storage_image_relation_abstract.py @@ -23,3 +23,4 @@ class ImageRelationAbstract(models.AbstractModel): image_name = fields.Char(related="image_id.name") image_alt_name = fields.Char(related="image_id.alt_name") image_url = fields.Char(related="image_id.image_medium_url") + is_public = fields.Boolean(related="image_id.file_id.is_public", readonly=True) diff --git a/storage_image/tests/__init__.py b/storage_image/tests/__init__.py index cedd944b24..fbe7e719dc 100644 --- a/storage_image/tests/__init__.py +++ b/storage_image/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_storage_image from . import test_storage_replace_file from . import test_swap_backend +from . import test_is_public diff --git a/storage_image/tests/test_is_public.py b/storage_image/tests/test_is_public.py new file mode 100644 index 0000000000..27938492d7 --- /dev/null +++ b/storage_image/tests/test_is_public.py @@ -0,0 +1,25 @@ +# Copyright 2026 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from .common import StorageImageCommonCase + + +class TestStorageImageIsPublic(StorageImageCommonCase): + def setUp(self): + super().setUp() + self.storage_image = self._create_storage_image_from_file( + "static/akretion-logo.png" + ) + + def test_is_public_reflects_backend(self): + self.assertFalse(self.storage_image.is_public) + self.backend.sudo().is_public = True + self.assertTrue(self.storage_image.is_public) + + def test_is_public_search(self): + self.backend.sudo().is_public = True + result = self.env["storage.image"].search( + [("is_public", "=", True), ("id", "=", self.storage_image.id)] + ) + self.assertEqual(self.storage_image, result) diff --git a/storage_image/views/storage_image.xml b/storage_image/views/storage_image.xml index f05dda0e0b..da685998cc 100644 --- a/storage_image/views/storage_image.xml +++ b/storage_image/views/storage_image.xml @@ -8,6 +8,7 @@ + @@ -86,6 +87,17 @@ + + + From 588bafa278a50023b5c29e2744635dd0adf2c91a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 21 May 2026 13:27:34 +0200 Subject: [PATCH 03/13] [IMP] storage_media: add search by public/private --- storage_media/tests/__init__.py | 1 + storage_media/tests/test_is_public.py | 28 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 storage_media/tests/test_is_public.py diff --git a/storage_media/tests/__init__.py b/storage_media/tests/__init__.py index 3a133375d2..1c48fb926f 100644 --- a/storage_media/tests/__init__.py +++ b/storage_media/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_storage_media from . import test_storage_replace_file from . import test_swap_backend +from . import test_is_public diff --git a/storage_media/tests/test_is_public.py b/storage_media/tests/test_is_public.py new file mode 100644 index 0000000000..5a210f1c73 --- /dev/null +++ b/storage_media/tests/test_is_public.py @@ -0,0 +1,28 @@ +# Copyright 2026 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.addons.component.tests.common import TransactionComponentCase + + +class TestStorageMediaIsPublic(TransactionComponentCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.backend = cls.env.ref("storage_backend.default_storage_backend") + cls.media = cls.env["storage.media"].create( + {"name": "test-media.txt", "backend_id": cls.backend.id} + ) + + def test_is_public_reflects_backend(self): + self.backend.sudo().is_public = False + self.assertFalse(self.media.is_public) + self.backend.sudo().is_public = True + self.assertTrue(self.media.is_public) + + def test_is_public_search(self): + self.backend.sudo().is_public = True + result = self.env["storage.media"].search( + [("is_public", "=", True), ("id", "=", self.media.id)] + ) + self.assertEqual(self.media, result) From 12982785311007c4da4c195bb362304e0a6b6e03 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Sun, 24 May 2026 12:37:52 +0200 Subject: [PATCH 04/13] [IMP] storage_media_product: show is_public --- storage_media_product/models/product.py | 1 + storage_media_product/views/product.xml | 1 + 2 files changed, 2 insertions(+) diff --git a/storage_media_product/models/product.py b/storage_media_product/models/product.py index 12832b70af..2b3ffb4e18 100644 --- a/storage_media_product/models/product.py +++ b/storage_media_product/models/product.py @@ -65,6 +65,7 @@ class ProductMediaRelation(models.Model): url = fields.Char(related="media_id.url", readonly=True) url_path = fields.Char(related="media_id.url_path", readonly=True) media_type_id = fields.Many2one(related="media_id.media_type_id", readonly=True) + is_public = fields.Boolean(related="media_id.file_id.is_public", readonly=True) @api.depends("media_id", "product_tmpl_id.attribute_line_ids.value_ids") def _compute_available_attribute(self): diff --git a/storage_media_product/views/product.xml b/storage_media_product/views/product.xml index 66095eb3ea..c76587a3eb 100644 --- a/storage_media_product/views/product.xml +++ b/storage_media_product/views/product.xml @@ -26,6 +26,7 @@ widget="many2many_tags" invisible="not available_attribute_value_ids" /> + From 9635392ad29070df666e807d0441da42268cab18 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 27 May 2026 16:13:24 +0200 Subject: [PATCH 05/13] [IMP] storage_file: make is_public visible all the times --- storage_file/views/storage_backend_view.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/storage_file/views/storage_backend_view.xml b/storage_file/views/storage_backend_view.xml index 219cd3fc84..f1d6f2e14e 100644 --- a/storage_file/views/storage_backend_view.xml +++ b/storage_file/views/storage_backend_view.xml @@ -15,7 +15,7 @@
Make sure this parameter is properly configured and accessible from everwhere you want to access the service. - + From a15e667b9cbdb9096636d2fbc5465f3a7f031744 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 28 May 2026 09:50:04 +0200 Subject: [PATCH 06/13] [IMP] storage_file: allow viewing external files via Odoo For the case of private files stored externally it might be necessary to view the file via Odoo directly because you can avoid to setup a private CDN with complex authentication (eg: token, vpn, etc). In this way, the files can be proxied by the controller. --- storage_file/controllers/main.py | 11 ++ storage_file/models/storage_backend.py | 5 +- storage_file/security/ir.model.access.csv | 1 + storage_file/security/storage_file.xml | 11 ++ storage_file/tests/__init__.py | 1 + storage_file/tests/test_controller.py | 156 ++++++++++++++++++++ storage_file/tests/test_storage_file.py | 11 ++ storage_file/views/storage_backend_view.xml | 10 ++ 8 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 storage_file/tests/test_controller.py diff --git a/storage_file/controllers/main.py b/storage_file/controllers/main.py index c2b29b44da..365b6b4682 100644 --- a/storage_file/controllers/main.py +++ b/storage_file/controllers/main.py @@ -1,7 +1,9 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. +from werkzeug.exceptions import NotFound from odoo import http +from odoo.exceptions import AccessError from odoo.http import request @@ -13,6 +15,15 @@ def content_common(self, slug_name_with_id, token=None, download=None, **kw): storage_file = request.env["storage.file"].get_from_slug_name_with_id( slug_name_with_id ) + if not storage_file.exists(): + raise NotFound() + try: + storage_file.check_access("read") + except AccessError as err: + # If you don't have access you should not know + # that the file exists (as anon user). + # You can inspect the traceback to see it's coming from an access error. + raise NotFound() from err stream = request.env["ir.binary"]._get_stream_from( storage_file, field_name="data" ) diff --git a/storage_file/models/storage_backend.py b/storage_file/models/storage_backend.py index c3a273a157..e011180b1b 100644 --- a/storage_file/models/storage_backend.py +++ b/storage_file/models/storage_backend.py @@ -145,7 +145,10 @@ def _get_base_url_from_param(self): def _get_url_for_file(self, storage_file, exclude_base_url=False): """Return final full URL for given file.""" backend = self.sudo() - if backend.served_by == "odoo": + # Make sure that no matter if you have a CDN URL or not, + # you can always access the file via Odoo. + force_serve_via_odoo = backend.served_by == "external" and not backend.base_url + if backend.served_by == "odoo" or force_serve_via_odoo: parts = [ self._get_base_url_from_param() if not exclude_base_url else "/", "storage.file", diff --git a/storage_file/security/ir.model.access.csv b/storage_file/security/ir.model.access.csv index 4532fee45a..dce3e6034e 100644 --- a/storage_file/security/ir.model.access.csv +++ b/storage_file/security/ir.model.access.csv @@ -1,5 +1,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_storage_file_edit,storage_file edit,model_storage_file,base.group_system,1,1,1,1 access_storage_file_read_public,storage_file public read,model_storage_file,base.group_user,1,0,0,0 +access_storage_file_read_portal,storage_file portal read,model_storage_file,base.group_public,1,0,0,0 access_storage_file_replace,storage_file_replace public,model_storage_file_replace,base.group_user,1,1,1,1 access_storage_file_swap_backend,storage_file_swap_backend admin,model_storage_file_swap_backend,base.group_system,1,1,1,1 diff --git a/storage_file/security/storage_file.xml b/storage_file/security/storage_file.xml index 2510d3defb..bb09998341 100644 --- a/storage_file/security/storage_file.xml +++ b/storage_file/security/storage_file.xml @@ -13,4 +13,15 @@ + + + Storage file internal read all + + + [(1, '=', 1)] + + + + + diff --git a/storage_file/tests/__init__.py b/storage_file/tests/__init__.py index 3aa707d6d8..e20c8f1eea 100644 --- a/storage_file/tests/__init__.py +++ b/storage_file/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_storage_file from . import test_swap_backend from . import test_is_public +from . import test_controller diff --git a/storage_file/tests/test_controller.py b/storage_file/tests/test_controller.py new file mode 100644 index 0000000000..511d987f54 --- /dev/null +++ b/storage_file/tests/test_controller.py @@ -0,0 +1,156 @@ +# Copyright 2026 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import base64 + +from odoo.tests.common import HttpCase, tagged + + +@tagged("-at_install", "post_install") +class TestStorageFileController(HttpCase): + """Test the /storage.file/ controller with public/private and odoo/external.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.data = b"Hello, storage!" + cls.filedata = base64.b64encode(cls.data) + cls.backend_odoo_public = cls.env["storage.backend"].create( + { + "name": "Odoo Public", + "backend_type": "filesystem", + "served_by": "odoo", + "is_public": True, + "filename_strategy": "name_with_id", + } + ) + cls.backend_odoo_private = cls.env["storage.backend"].create( + { + "name": "Odoo Private", + "backend_type": "filesystem", + "served_by": "odoo", + "is_public": False, + "filename_strategy": "name_with_id", + } + ) + cls.backend_ext_public = cls.env["storage.backend"].create( + { + "name": "Ext Public (no CDN)", + "backend_type": "filesystem", + "served_by": "external", + "base_url": "", + "is_public": True, + "filename_strategy": "name_with_id", + } + ) + cls.backend_ext_private = cls.env["storage.backend"].create( + { + "name": "Ext Private (no CDN)", + "backend_type": "filesystem", + "served_by": "external", + "base_url": "", + "is_public": False, + "filename_strategy": "name_with_id", + } + ) + cls.file_odoo_public = cls.env["storage.file"].create( + { + "name": "pub-odoo.txt", + "backend_id": cls.backend_odoo_public.id, + "data": cls.filedata, + } + ) + cls.file_odoo_private = cls.env["storage.file"].create( + { + "name": "priv-odoo.txt", + "backend_id": cls.backend_odoo_private.id, + "data": cls.filedata, + } + ) + cls.file_ext_public = cls.env["storage.file"].create( + { + "name": "pub-ext.txt", + "backend_id": cls.backend_ext_public.id, + "data": cls.filedata, + } + ) + cls.file_ext_private = cls.env["storage.file"].create( + { + "name": "priv-ext.txt", + "backend_id": cls.backend_ext_private.id, + "data": cls.filedata, + } + ) + cls.internal_user = ( + cls.env["res.users"] + .with_context(no_reset_password=True) + .create( + { + "name": "Storage Test User", + "login": "storage_test_user", + "password": "storage_test_user", + "groups_id": [ + (4, cls.env.ref("base.group_user").id), + ], + } + ) + ) + + def _url_for(self, storage_file): + return f"/storage.file/{storage_file.slug}" + + # ---- Public user (anonymous) ---- + + def test_public_user_odoo_public(self): + """Public user + public odoo backend -> 200.""" + resp = self.url_open(self._url_for(self.file_odoo_public)) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.content, self.data) + + def test_public_user_odoo_private(self): + """Public user + private odoo backend -> 404.""" + resp = self.url_open(self._url_for(self.file_odoo_private)) + self.assertEqual(resp.status_code, 404) + + def test_public_user_ext_public(self): + """Public user + public external backend (no CDN) -> 200.""" + resp = self.url_open(self._url_for(self.file_ext_public)) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.content, self.data) + + def test_public_user_ext_private(self): + """Public user + private external backend (no CDN) -> 404.""" + resp = self.url_open(self._url_for(self.file_ext_private)) + self.assertEqual(resp.status_code, 404) + + # ---- Internal (authenticated) user ---- + + def test_internal_user_odoo_public(self): + """Internal user + public odoo backend -> 200.""" + self.authenticate("storage_test_user", "storage_test_user") + resp = self.url_open(self._url_for(self.file_odoo_public)) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.content, self.data) + + def test_internal_user_odoo_private(self): + """Internal user + private odoo backend -> 200.""" + self.authenticate("storage_test_user", "storage_test_user") + resp = self.url_open(self._url_for(self.file_odoo_private)) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.content, self.data) + + def test_internal_user_ext_public(self): + """Internal user + public external backend (no CDN) -> 200.""" + self.authenticate("storage_test_user", "storage_test_user") + resp = self.url_open(self._url_for(self.file_ext_public)) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.content, self.data) + + def test_internal_user_ext_private(self): + """Internal user + private external backend (no CDN) -> 200.""" + self.authenticate("storage_test_user", "storage_test_user") + resp = self.url_open(self._url_for(self.file_ext_private)) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.content, self.data) diff --git a/storage_file/tests/test_storage_file.py b/storage_file/tests/test_storage_file.py index fa540cbcf9..919c817e42 100644 --- a/storage_file/tests/test_storage_file.py +++ b/storage_file/tests/test_storage_file.py @@ -110,6 +110,17 @@ def test_url(self): stfile.url, f"https://foo.com/baz/test-of-my_file-{stfile.id}.txt" ) + def test_url_external_no_base_url_falls_back_to_odoo(self): + """External backend w/o base_url uses the internal Odoo route.""" + stfile = self._create_storage_file() + params = self.env["ir.config_parameter"].sudo() + base_url = params.get_param("web.base.url") + stfile.backend_id.update({"served_by": "external", "base_url": ""}) + self.assertEqual( + stfile.url, + f"{base_url}/storage.file/test-of-my_file-{stfile.id}.txt", + ) + def test_url_without_base_url(self): stfile = self._create_storage_file() # served by odoo diff --git a/storage_file/views/storage_backend_view.xml b/storage_file/views/storage_backend_view.xml index f1d6f2e14e..31e4ceb56b 100644 --- a/storage_file/views/storage_backend_view.xml +++ b/storage_file/views/storage_backend_view.xml @@ -17,6 +17,16 @@ + From b29b20842c1c4135c984595e8ca1245d8ab1b104 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 28 May 2026 12:39:40 +0200 Subject: [PATCH 07/13] [IMP] storage_file: edit backend and pre-load default So far the backend was added only auto-magically on media or image creation. However, this is a partial behavior and since the backend is not editable you cannot decide where to put your file if not via global conf. This chance allows to edit the backend while still pre-loading the default one. --- storage_file/__manifest__.py | 1 + storage_file/data/ir_config_parameter.xml | 7 +++++++ storage_file/models/storage_file.py | 12 ++++++++++- storage_file/tests/test_storage_file.py | 21 +++++++++++++++++++ storage_image/tests/test_storage_image.py | 24 ++++++++++++++++++++++ storage_image/views/storage_image.xml | 1 + storage_media/tests/test_storage_media.py | 20 ++++++++++++++++++ storage_media/views/storage_media_view.xml | 1 + 8 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 storage_file/data/ir_config_parameter.xml diff --git a/storage_file/__manifest__.py b/storage_file/__manifest__.py index 91abd363a5..efdbea4fd9 100644 --- a/storage_file/__manifest__.py +++ b/storage_file/__manifest__.py @@ -23,5 +23,6 @@ "data/ir_cron.xml", "data/storage_backend.xml", "wizards/swap_backend.xml", + "data/ir_config_parameter.xml", ], } diff --git a/storage_file/data/ir_config_parameter.xml b/storage_file/data/ir_config_parameter.xml new file mode 100644 index 0000000000..68dafd61d1 --- /dev/null +++ b/storage_file/data/ir_config_parameter.xml @@ -0,0 +1,7 @@ + + + + storage.file.backend_id + + + diff --git a/storage_file/models/storage_file.py b/storage_file/models/storage_file.py index 12d05469ea..7816fc3122 100644 --- a/storage_file/models/storage_file.py +++ b/storage_file/models/storage_file.py @@ -30,7 +30,11 @@ class StorageFile(models.Model): name = fields.Char(required=True, index=True) backend_id = fields.Many2one( - "storage.backend", "Storage", index=True, required=True + "storage.backend", + "Storage", + index=True, + required=True, + default=lambda self: self._get_default_backend_id(), ) url = fields.Char(compute="_compute_url", help="HTTP accessible path to the file") url_path = fields.Char( @@ -78,6 +82,12 @@ def _compute_is_public(self): for rec in self: rec.is_public = rec.backend_id.is_public + @api.model + def _get_default_backend_id(self): + return self.env["storage.backend"]._get_backend_id_from_param( + self.env, "storage.file.backend_id" + ) + def _search_is_public(self, operator, value): # Look up matching backends with sudo so that users with limited ACL # on `storage.backend` can still filter their accessible files by diff --git a/storage_file/tests/test_storage_file.py b/storage_file/tests/test_storage_file.py index 919c817e42..46a3084d7e 100644 --- a/storage_file/tests/test_storage_file.py +++ b/storage_file/tests/test_storage_file.py @@ -7,6 +7,7 @@ from urllib import parse from odoo.exceptions import AccessError, UserError +from odoo.tests import Form from odoo.addons.component.tests.common import TransactionComponentCase @@ -317,3 +318,23 @@ def test_empty(self): # get_url is called on new records empty = self.env["storage.file"].new({})._get_url() self.assertEqual(empty, "") + + def test_default_backend_id_on_form(self): + """Form pre-fills the default backend for storage.file.""" + form = Form(self.env["storage.file"]) + self.assertEqual(form.backend_id, self.backend) + + def test_default_backend_id_from_param(self): + """storage.file.backend_id param overrides the form default.""" + other_backend = self.env["storage.backend"].create( + { + "name": "Other", + "backend_type": "filesystem", + "filename_strategy": "name_with_id", + } + ) + self.env["ir.config_parameter"].sudo().set_param( + "storage.file.backend_id", str(other_backend.id) + ) + form = Form(self.env["storage.file"]) + self.assertEqual(form.backend_id, other_backend) diff --git a/storage_image/tests/test_storage_image.py b/storage_image/tests/test_storage_image.py index de3f128065..bccce9c4ed 100644 --- a/storage_image/tests/test_storage_image.py +++ b/storage_image/tests/test_storage_image.py @@ -115,3 +115,27 @@ def test_create_thumbnail_pilbox(self): "&w=64&h=64&mode=fill&fmt=webp", urls, ) + + def test_default_backend_id_on_form(self): + """Creating an image without backend_id uses the configured default.""" + image = self._create_storage_image(self.filename, self.filedata) + self.assertEqual(image.backend_id, self.backend) + + def test_default_backend_id_from_param(self): + """storage.image.backend_id param overrides backend on create.""" + other_backend = ( + self.env["storage.backend"] + .sudo() + .create( + { + "name": "Image Backend", + "backend_type": "filesystem", + "filename_strategy": "name_with_id", + } + ) + ) + self.env["ir.config_parameter"].sudo().set_param( + "storage.image.backend_id", str(other_backend.id) + ) + image = self._create_storage_image(self.filename, self.filedata) + self.assertEqual(image.backend_id.id, other_backend.id) diff --git a/storage_image/views/storage_image.xml b/storage_image/views/storage_image.xml index da685998cc..500215d0d3 100644 --- a/storage_image/views/storage_image.xml +++ b/storage_image/views/storage_image.xml @@ -40,6 +40,7 @@ + diff --git a/storage_media/tests/test_storage_media.py b/storage_media/tests/test_storage_media.py index 5d1dc4a123..ccb1e91601 100644 --- a/storage_media/tests/test_storage_media.py +++ b/storage_media/tests/test_storage_media.py @@ -34,3 +34,23 @@ def test_unlink(self): media.unlink() self.assertEqual(stfile.to_delete, True) self.assertEqual(stfile.active, False) + + def test_default_backend_id_on_form(self): + """Creating a media without backend_id uses the configured default.""" + media = self.env["storage.media"].create({"name": "default-test.txt"}) + self.assertEqual(media.backend_id, self.backend) + + def test_default_backend_id_from_param(self): + """storage.media.backend_id param overrides backend on create.""" + other_backend = self.env["storage.backend"].create( + { + "name": "Media Backend", + "backend_type": "filesystem", + "filename_strategy": "name_with_id", + } + ) + self.env["ir.config_parameter"].sudo().set_param( + "storage.media.backend_id", str(other_backend.id) + ) + media = self.env["storage.media"].create({"name": "test.txt"}) + self.assertEqual(media.backend_id.id, other_backend.id) diff --git a/storage_media/views/storage_media_view.xml b/storage_media/views/storage_media_view.xml index 4e47ac99c3..2cfa3d57e6 100644 --- a/storage_media/views/storage_media_view.xml +++ b/storage_media/views/storage_media_view.xml @@ -34,6 +34,7 @@ + From d4959cc89cf9320ddd3951743eddff0e8df09581 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 28 May 2026 12:40:30 +0200 Subject: [PATCH 08/13] [FIX] storage_file: _get_url returns nothing if no backend is set --- storage_file/models/storage_file.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/storage_file/models/storage_file.py b/storage_file/models/storage_file.py index 7816fc3122..b76c0d05e5 100644 --- a/storage_file/models/storage_file.py +++ b/storage_file/models/storage_file.py @@ -206,6 +206,8 @@ def _get_url(self, exclude_base_url=False): :param exclude_base_url: skip base_url """ + if not self.backend_id or not self.relative_path: + return "" return self.backend_id._get_url_for_file( self, exclude_base_url=exclude_base_url ) From 7b339a53f9e8337eafd3b3afbd2aca333b8322cd Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 28 May 2026 13:45:22 +0200 Subject: [PATCH 09/13] [FIX] storage_thumbnail: test data Provide data so that the URL is generated properly. --- storage_thumbnail/tests/test_thumbnail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/storage_thumbnail/tests/test_thumbnail.py b/storage_thumbnail/tests/test_thumbnail.py index 29e17bc3fc..384c3f2a1d 100644 --- a/storage_thumbnail/tests/test_thumbnail.py +++ b/storage_thumbnail/tests/test_thumbnail.py @@ -33,7 +33,7 @@ def check_attrs(self): def _create_thumbnail(self): # create thumbnail - vals = {"name": "TEST THUMB"} + vals = {"name": "TEST THUMB", "data": self.filedata} return self.env["storage.thumbnail"].create(vals) def _create_image(self, resize=False, **kw): From 71ccf1b16c3343ea7e05764db59ed0cf7aea1dee Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 10 Jun 2026 12:24:00 +0200 Subject: [PATCH 10/13] [IMP] storage_file: allow to group by backend --- storage_file/views/storage_file_view.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/storage_file/views/storage_file_view.xml b/storage_file/views/storage_file_view.xml index 200cbc9532..a922416d1d 100644 --- a/storage_file/views/storage_file_view.xml +++ b/storage_file/views/storage_file_view.xml @@ -55,6 +55,13 @@ name="private" domain="[('is_public', '=', False)]" /> + + + From 5675d61553d2405d7c626175ee08e2169ddc6767 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 10 Jun 2026 12:25:30 +0200 Subject: [PATCH 11/13] [IMP] storage_image*: improve views * group by backend * search by public/private --- .../models/storage_image_relation_abstract.py | 1 + storage_image/views/storage_image.xml | 8 ++++++++ storage_image_product/views/storage_image.xml | 16 ++++++++++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/storage_image/models/storage_image_relation_abstract.py b/storage_image/models/storage_image_relation_abstract.py index 838f625130..4f0723a5a4 100644 --- a/storage_image/models/storage_image_relation_abstract.py +++ b/storage_image/models/storage_image_relation_abstract.py @@ -24,3 +24,4 @@ class ImageRelationAbstract(models.AbstractModel): image_alt_name = fields.Char(related="image_id.alt_name") image_url = fields.Char(related="image_id.image_medium_url") is_public = fields.Boolean(related="image_id.file_id.is_public", readonly=True) + active = fields.Boolean(related="image_id.active", readonly=True) diff --git a/storage_image/views/storage_image.xml b/storage_image/views/storage_image.xml index 500215d0d3..42338506e2 100644 --- a/storage_image/views/storage_image.xml +++ b/storage_image/views/storage_image.xml @@ -88,6 +88,7 @@ + + + + diff --git a/storage_image_product/views/storage_image.xml b/storage_image_product/views/storage_image.xml index c93cd0fdb2..c3a4cd140f 100644 --- a/storage_image_product/views/storage_image.xml +++ b/storage_image_product/views/storage_image.xml @@ -7,11 +7,17 @@ - + + +
@@ -27,9 +33,15 @@ - + + + From 1c9ae67550a5b74780f93afcf4ebc0690f6e1c4d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 10 Jun 2026 12:26:10 +0200 Subject: [PATCH 12/13] [IMP] storage_media*: improve views * group by backend * search by public/privte --- storage_media/views/storage_media_view.xml | 20 ++++++++++++++++++++ storage_media_product/models/product.py | 1 + storage_media_product/views/product.xml | 8 +++++++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/storage_media/views/storage_media_view.xml b/storage_media/views/storage_media_view.xml index 2cfa3d57e6..20ba7494c6 100644 --- a/storage_media/views/storage_media_view.xml +++ b/storage_media/views/storage_media_view.xml @@ -6,6 +6,7 @@ + @@ -46,8 +47,20 @@ + + + + + + + diff --git a/storage_media_product/models/product.py b/storage_media_product/models/product.py index 2b3ffb4e18..a885f7f80d 100644 --- a/storage_media_product/models/product.py +++ b/storage_media_product/models/product.py @@ -66,6 +66,7 @@ class ProductMediaRelation(models.Model): url_path = fields.Char(related="media_id.url_path", readonly=True) media_type_id = fields.Many2one(related="media_id.media_type_id", readonly=True) is_public = fields.Boolean(related="media_id.file_id.is_public", readonly=True) + active = fields.Boolean(related="media_id.active", readonly=True) @api.depends("media_id", "product_tmpl_id.attribute_line_ids.value_ids") def _compute_available_attribute(self): diff --git a/storage_media_product/views/product.xml b/storage_media_product/views/product.xml index c76587a3eb..d03831bbfa 100644 --- a/storage_media_product/views/product.xml +++ b/storage_media_product/views/product.xml @@ -35,9 +35,15 @@ product.media.relation - + + + From fcb16d8896965b25ed41300d66337585f2a52830 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 10 Jun 2026 12:47:09 +0200 Subject: [PATCH 13/13] checklog-odoo: ignore warning from assets generation --- checklog-odoo.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/checklog-odoo.cfg b/checklog-odoo.cfg index 0b55b7bf66..ce6fa8d678 100644 --- a/checklog-odoo.cfg +++ b/checklog-odoo.cfg @@ -1,3 +1,4 @@ [checklog-odoo] ignore= WARNING.* 0 failed, 0 error\(s\).* + WARNING.*DeprecationWarning: PyUnicode_FromUnicode\(NULL, size\) is deprecated; use PyUnicode_New\(\) instead