From a250494410ea0a8c5268d9e7fc1b6d803aaf07de Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 2 Oct 2026 14:26:56 +0200 Subject: [PATCH 1/7] [FIX] storage_file: fix ctx key to force deletion Was mispelled and not really meaningful. --- storage_file/models/storage_file.py | 4 ++-- .../tests/test_swap_backend_queue.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/storage_file/models/storage_file.py b/storage_file/models/storage_file.py index f5b5282c12..6811580e24 100644 --- a/storage_file/models/storage_file.py +++ b/storage_file/models/storage_file.py @@ -241,7 +241,7 @@ def _compute_extract_filename(self): rec.mimetype = mime def unlink(self): - if self._context.get("cleanning_storage_file"): + if self.env.context.get("force_delete_storage_file"): super().unlink() else: self.write({"to_delete": True, "active": False}) @@ -261,7 +261,7 @@ def _clean_storage_file(self, batch_size=1000): recordset = self.browse(ids[:batch_size]) for st_file in recordset: st_file.backend_id.sudo().delete(st_file.relative_path) - st_file.with_context(cleanning_storage_file=True).unlink() + st_file.with_context(force_delete_storage_file=True).unlink() # commit is required since the backend could be an external system # therefore, if the record is deleted on the external system # we must be sure that the record is also deleted into Odoo diff --git a/storage_file_swap_backend_queue/tests/test_swap_backend_queue.py b/storage_file_swap_backend_queue/tests/test_swap_backend_queue.py index 54b5e7d673..d8f32155e5 100644 --- a/storage_file_swap_backend_queue/tests/test_swap_backend_queue.py +++ b/storage_file_swap_backend_queue/tests/test_swap_backend_queue.py @@ -111,7 +111,7 @@ def test_job_handles_missing_record(self): """Deleted records between enqueue and execution are reported.""" stfile = self._create_storage_file(data=b"payload") file_id = stfile.id - stfile.with_context(cleanning_storage_file=True).unlink() + stfile.with_context(force_delete_storage_file=True).unlink() records = self.env["storage.file"].browse(file_id) result = records._swap_backend_job(self.backend_b.id) self.assertIn("no longer exists", result) From 51d21abcb5190d5289f80847e7f088d90eaa0b96 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 2 Oct 2026 14:28:58 +0200 Subject: [PATCH 2/7] [IMP] storage_file: strengthen _clean_storage_file cron Avoid full break with corrupted files, just log an error. --- storage_file/models/storage_file.py | 9 +++++++++ storage_file/tests/test_storage_file.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/storage_file/models/storage_file.py b/storage_file/models/storage_file.py index 6811580e24..995301275d 100644 --- a/storage_file/models/storage_file.py +++ b/storage_file/models/storage_file.py @@ -259,13 +259,22 @@ def _clean_storage_file(self, batch_size=1000): ) ids = [x[0] for x in self._cr.fetchall()] recordset = self.browse(ids[:batch_size]) + corrupted_ids = [] for st_file in recordset: + if not st_file.relative_path: + corrupted_ids.append(st_file.id) + continue st_file.backend_id.sudo().delete(st_file.relative_path) st_file.with_context(force_delete_storage_file=True).unlink() # commit is required since the backend could be an external system # therefore, if the record is deleted on the external system # we must be sure that the record is also deleted into Odoo st_file._cr.commit() + if corrupted_ids: + _logger.error( + "Skipping corrupted files with no relative_path IDS=%s", + ",".join(map(str, corrupted_ids)), + ) done = len(recordset) self.env["ir.cron"]._notify_progress( done=done, remaining=0 if done <= batch_size else len(ids) - done diff --git a/storage_file/tests/test_storage_file.py b/storage_file/tests/test_storage_file.py index 46a3084d7e..88606fc0b8 100644 --- a/storage_file/tests/test_storage_file.py +++ b/storage_file/tests/test_storage_file.py @@ -227,6 +227,24 @@ def test_unlink(self): self.assertEqual(len(files), 0) self.assertNotIn(relative_path, backend.list_files()) + def test_clean_storage_file_skips_corrupted(self): + # Do not commit during the test + self.cr.commit = lambda: True + stfile = self._create_storage_file() + corrupted = self.env["storage.file"].create( + {"name": "corrupted.txt", "backend_id": stfile.backend_id.id} + ) + self.assertFalse(corrupted.relative_path) + (stfile | corrupted).unlink() + with self.assertLogs( + "odoo.addons.storage_file.models.storage_file", level="ERROR" + ) as logs: + self.env["storage.file"]._clean_storage_file() + self.assertIn(str(corrupted.id), logs.output[0]) + # The valid file is cleaned, the corrupted one is kept + self.assertFalse(stfile.exists()) + self.assertTrue(corrupted.exists()) + def test_public_access1(self): """ Test the public access (when is_public on the backend). From eab58a9786b56afaed00d7d7d16f29da02ed376a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 1 Oct 2026 17:25:06 +0200 Subject: [PATCH 3/7] [IMP] storage_file: replace wizard can keep history Add 'keep_history' and 'keep_relations' options to the replace wizard, with the shared logic to copy and archive the record owning the replaced file, and hooks to handle its relations. Provide a base form for the wizard, to extend by the modules defining the records owning files. Without 'keep_history', the replaced file is dropped: nothing refers to it anymore. The copy reuses the old file and its backend: otherwise the creation writes the default backend on the reused file, which moves it. --- storage_file/__manifest__.py | 1 + storage_file/wizards/replace_file.py | 86 ++++++++++++++++++++++++++- storage_file/wizards/replace_file.xml | 34 +++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 storage_file/wizards/replace_file.xml diff --git a/storage_file/__manifest__.py b/storage_file/__manifest__.py index e0fa530ac1..829cc3829f 100644 --- a/storage_file/__manifest__.py +++ b/storage_file/__manifest__.py @@ -22,6 +22,7 @@ "security/storage_file.xml", "data/ir_cron.xml", "data/storage_backend.xml", + "wizards/replace_file.xml", "wizards/swap_backend.xml", "data/ir_config_parameter.xml", ], diff --git a/storage_file/wizards/replace_file.py b/storage_file/wizards/replace_file.py index 2d73e50733..e2fd28b62a 100644 --- a/storage_file/wizards/replace_file.py +++ b/storage_file/wizards/replace_file.py @@ -1,7 +1,7 @@ # Copyright 2023 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -from odoo import fields, models +from odoo import api, fields, models class StorageFileReplace(models.TransientModel): @@ -11,6 +11,32 @@ class StorageFileReplace(models.TransientModel): file_id = fields.Many2one("storage.file") data = fields.Binary() file_name = fields.Char() + keep_history = fields.Boolean( + default=True, + help="Keep an archived copy of the record holding the replaced file.", + ) + # Support for relations is up to the modules defining them + keep_relations = fields.Boolean( + default=True, + help="Link the archived copy to the same records.", + ) + keep_relations_visible = fields.Boolean( + compute="_compute_keep_relations_visible", + ) + + @api.depends("keep_history") + def _compute_keep_relations_visible(self): + for rec in self: + rec.keep_relations_visible = ( + rec.keep_history and rec._has_replaced_relations() + ) + + def _has_replaced_relations(self): + """Tell if the record owning the replaced file has relations to keep. + + Hook for the modules defining relations to it. + """ + return False def _get_file_from_data(self): file_model = self.env["storage.file"].sudo() @@ -25,3 +51,61 @@ def _prepare_file_values(self): def confirm(self): return + + def _replace_file(self, owner): + """Make ``owner`` point to a new file built from the wizard data. + + Without ``keep_history``, the replaced file is dropped: nothing + refers to it anymore. + + :param owner: record delegating to 'storage.file' via ``file_id`` + :return: the archived copy of ``owner`` if ``keep_history`` is set + """ + old_file = owner.file_id + archived = self.keep_history and self._copy_replaced(owner) + # Creating the file with its data already stores it on the backend + owner.file_id = self._get_file_from_data() + if archived: + # 'active' is the one of the file, shared with ``owner`` + # until the replacement: archive it only now. + archived.active = False + else: + self._drop_replaced_file(old_file) + return archived + + def _drop_replaced_file(self, old_file): + """Drop the replaced file, not owned by any record anymore.""" + old_file = old_file.sudo() + # Forcing the deletion skips the cleanup cron, which also removes + # the file from the backend: do it here. + if old_file.relative_path: + old_file.backend_id.delete(old_file.relative_path) + old_file.with_context(force_delete_storage_file=True).unlink() + + def _copy_replaced(self, owner): + """Copy ``owner``, keeping the file being replaced. + + The copy is returned active: it is archived by ``_replace_file`` + once ``owner`` points to the new file. + """ + archived = owner.sudo().copy(self._copy_replaced_prepare_values(owner)) + if self.keep_relations: + self._copy_replaced_relations(owner, archived) + return archived + + def _copy_replaced_relations(self, owner, archived): + """Link ``archived`` to the same records as ``owner``. + + Hook for the modules defining relations to ``owner``. + """ + return + + def _copy_replaced_prepare_values(self, owner): + old_file = owner.file_id + return { + # Reuse the file instead of duplicating it + "file_id": old_file.id, + # Otherwise the creation sets the default backend of the owner + # and writes it on the reused file, which moves it. + "backend_id": old_file.backend_id.id, + } diff --git a/storage_file/wizards/replace_file.xml b/storage_file/wizards/replace_file.xml new file mode 100644 index 0000000000..20e10e56e7 --- /dev/null +++ b/storage_file/wizards/replace_file.xml @@ -0,0 +1,34 @@ + + + + + storage.file.replace.form + storage.file.replace + +
+ + + + + + + + + + + + + +
+
+
+
+
+
+
From 1a91dc48fc842d5b72de914efc4071e934786d90 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 1 Oct 2026 17:25:15 +0200 Subject: [PATCH 4/7] [IMP] storage_media: replace file can keep history Use the shared replace logic from storage_file and extend its wizard form. Drop the explicit '_inverse_data' call: creating the file with its data already stores it on the backend. --- .../tests/test_storage_replace_file.py | 76 +++++++++++++++++++ storage_media/wizards/replace_file.py | 3 +- storage_media/wizards/replace_file.xml | 20 ++--- 3 files changed, 82 insertions(+), 17 deletions(-) diff --git a/storage_media/tests/test_storage_replace_file.py b/storage_media/tests/test_storage_replace_file.py index 187be440c6..28fbb445bd 100644 --- a/storage_media/tests/test_storage_replace_file.py +++ b/storage_media/tests/test_storage_replace_file.py @@ -56,3 +56,79 @@ def test_wizard_change_file(self): self.assertEqual(media.file_id.name, self.filename_1) self.assertEqual(media.file_id.data, self.filedata_1) self.assertEqual(media.file_id.file_type, "media") + + def _replace_media_file(self, media, **values): + wiz_form = Form( + self.env["storage.file.replace"].with_context( + **{"active_model": "storage.media", "active_id": media.id} + ), + view="storage_media.storage_file_replace_view_form", + ) + wiz_form.file_name = self.filename_2 + wiz_form.data = self.filedata_2 + for key, value in values.items(): + setattr(wiz_form, key, value) + wiz = wiz_form.save() + wiz.confirm() + + def test_wizard_change_file_no_history(self): + media = self.env["storage.media"].create( + {"name": self.filename_1, "data": self.filedata_1} + ) + media_model = self.env["storage.media"].with_context(active_test=False) + media_count = media_model.search_count([]) + old_file = media.file_id + backend, relative_path = old_file.backend_id, old_file.relative_path + self._replace_media_file(media, keep_history=False) + self.assertEqual(media_model.search_count([]), media_count) + # Nothing refers to the old file anymore: it is deleted + self.assertFalse(old_file.exists()) + self.assertNotIn(relative_path, backend.list_files()) + self.assertTrue(media.active) + self.assertIn(media.file_id.relative_path, backend.list_files()) + + def test_wizard_change_file_keep_history(self): + media = self.env["storage.media"].create( + {"name": self.filename_1, "data": self.filedata_1} + ) + old_file = media.file_id + self._replace_media_file(media, keep_history=True) + # The media holds the new file and stays active + self.assertTrue(media.active) + self.assertNotEqual(media.file_id, old_file) + self.assertEqual(media.file_id.data, self.filedata_2) + # An archived copy holds the old file, untouched + archived_media = self.env["storage.media"].search( + [("file_id", "=", old_file.id), ("active", "=", False)] + ) + self.assertEqual(len(archived_media), 1) + self.assertEqual(archived_media.name, self.filename_1) + self.assertEqual(archived_media.data, self.filedata_1) + self.assertEqual(archived_media.backend_id, old_file.backend_id) + self.assertFalse(old_file.to_delete) + + def test_wizard_change_file_keep_history_keeps_backend(self): + # The old file is not on the default media backend: + # keeping its history must not move it. + default_backend = self.env["storage.backend"].browse( + self.env["storage.media"]._get_default_backend_id() + ) + other_backend = self.env["storage.backend"].create( + { + "name": "Other backend", + "backend_type": "filesystem", + "directory_path": "other_backend", + "categ_id": default_backend.categ_id.id, + } + ) + media = self.env["storage.media"].create( + { + "name": self.filename_1, + "data": self.filedata_1, + "backend_id": other_backend.id, + } + ) + old_file = media.file_id + self._replace_media_file(media, keep_history=True) + self.assertEqual(old_file.backend_id, other_backend) + self.assertEqual(old_file.data, self.filedata_1) diff --git a/storage_media/wizards/replace_file.py b/storage_media/wizards/replace_file.py index 517521a590..0e3d73ecc1 100644 --- a/storage_media/wizards/replace_file.py +++ b/storage_media/wizards/replace_file.py @@ -34,6 +34,5 @@ def _prepare_file_values(self): def confirm(self): res = super().confirm() if self.media_id and self.data: - self.media_id.file_id = self._get_file_from_data() - self.media_id.file_id._inverse_data() + self._replace_file(self.media_id) return res diff --git a/storage_media/wizards/replace_file.xml b/storage_media/wizards/replace_file.xml index 86aaf8eb0b..716ae47900 100644 --- a/storage_media/wizards/replace_file.xml +++ b/storage_media/wizards/replace_file.xml @@ -3,23 +3,13 @@ License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). --> - storage.file.replace.form storage.file.replace + + primary -
- - - - - - - -
-
-
-
+ + Media +
From c4234dbea5d8144638b789e036e63e278ff570e4 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 1 Oct 2026 17:25:19 +0200 Subject: [PATCH 5/7] [IMP] storage_image: replace file can keep history Use the shared replace logic from storage_file and extend its wizard form. --- .../tests/test_storage_replace_file.py | 45 +++++++++++++++++++ storage_image/wizards/replace_file.py | 2 +- storage_image/wizards/replace_file.xml | 36 ++++++--------- 3 files changed, 59 insertions(+), 24 deletions(-) diff --git a/storage_image/tests/test_storage_replace_file.py b/storage_image/tests/test_storage_replace_file.py index 985f704191..18c4e16d46 100644 --- a/storage_image/tests/test_storage_replace_file.py +++ b/storage_image/tests/test_storage_replace_file.py @@ -58,3 +58,48 @@ def test_wizard_change_file(self): self.assertEqual(image.file_id.name, self.filename_1) self.assertEqual(image.file_id.data, self.filedata_1) self.assertEqual(image.file_id.file_type, "image") + + def test_wizard_change_file_keep_history(self): + image = self._create_storage_image(self.filename_1, self.filedata_1) + old_file = image.file_id + wiz_form = Form( + self.env["storage.file.replace"].with_context( + **{"active_model": "storage.image", "active_id": image.id} + ), + view="storage_image.storage_file_replace_view_form", + ) + wiz_form.file_name = self.filename_2 + wiz_form.data = self.filedata_2 + wiz_form.keep_history = True + wiz_form.save().confirm() + # The image holds the new file and stays active + self.assertTrue(image.active) + self.assertEqual(image.file_id.data, self.filedata_2) + # An archived copy holds the old file, untouched + archived_image = self.env["storage.image"].search( + [("file_id", "=", old_file.id), ("active", "=", False)] + ) + self.assertEqual(len(archived_image), 1) + self.assertEqual(archived_image.data, self.filedata_1) + self.assertEqual(archived_image.backend_id, old_file.backend_id) + self.assertFalse(old_file.to_delete) + + def test_wizard_change_file_no_history(self): + image = self._create_storage_image(self.filename_1, self.filedata_1) + old_file = image.file_id + backend, relative_path = old_file.backend_id, old_file.relative_path + wiz_form = Form( + self.env["storage.file.replace"].with_context( + **{"active_model": "storage.image", "active_id": image.id} + ), + view="storage_image.storage_file_replace_view_form", + ) + wiz_form.file_name = self.filename_2 + wiz_form.data = self.filedata_2 + wiz_form.keep_history = False + wiz_form.save().confirm() + self.assertTrue(image.active) + self.assertEqual(image.file_id.data, self.filedata_2) + # Nothing refers to the old file anymore: it is deleted + self.assertFalse(old_file.exists()) + self.assertNotIn(relative_path, backend.list_files()) diff --git a/storage_image/wizards/replace_file.py b/storage_image/wizards/replace_file.py index 3698ab5d65..12407e6a9b 100644 --- a/storage_image/wizards/replace_file.py +++ b/storage_image/wizards/replace_file.py @@ -34,5 +34,5 @@ def _prepare_file_values(self): def confirm(self): res = super().confirm() if self.image_id and self.data: - self.image_id.file_id = self._get_file_from_data() + self._replace_file(self.image_id) return res diff --git a/storage_image/wizards/replace_file.xml b/storage_image/wizards/replace_file.xml index ac7b062261..6ee195071c 100644 --- a/storage_image/wizards/replace_file.xml +++ b/storage_image/wizards/replace_file.xml @@ -3,31 +3,21 @@ License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). --> - storage.file.replace.form storage.file.replace + + primary -
- - - - - - - - - -
-
-
-
+ + + + + Image + + + image + 1 + {'size': (128, 128)} +
From eadcbf6b8c606986376f233dbaa5ba01d3c82921 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 1 Oct 2026 17:25:43 +0200 Subject: [PATCH 6/7] [IMP] storage_media_product: keep relations on replace On file replacement, copy the product relations and the categories of the media to its archived copy when 'keep_relations' is set. --- storage_media_product/__init__.py | 1 + storage_media_product/tests/__init__.py | 1 + .../tests/test_replace_file.py | 81 +++++++++++++++++++ storage_media_product/wizards/__init__.py | 1 + storage_media_product/wizards/replace_file.py | 30 +++++++ 5 files changed, 114 insertions(+) create mode 100644 storage_media_product/tests/__init__.py create mode 100644 storage_media_product/tests/test_replace_file.py create mode 100644 storage_media_product/wizards/__init__.py create mode 100644 storage_media_product/wizards/replace_file.py diff --git a/storage_media_product/__init__.py b/storage_media_product/__init__.py index 0650744f6b..aee8895e7a 100644 --- a/storage_media_product/__init__.py +++ b/storage_media_product/__init__.py @@ -1 +1,2 @@ from . import models +from . import wizards diff --git a/storage_media_product/tests/__init__.py b/storage_media_product/tests/__init__.py new file mode 100644 index 0000000000..16a503c1b8 --- /dev/null +++ b/storage_media_product/tests/__init__.py @@ -0,0 +1 @@ +from . import test_replace_file diff --git a/storage_media_product/tests/test_replace_file.py b/storage_media_product/tests/test_replace_file.py new file mode 100644 index 0000000000..ed2a1b2303 --- /dev/null +++ b/storage_media_product/tests/test_replace_file.py @@ -0,0 +1,81 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import base64 + +from odoo.tests import Form + +from odoo.addons.component.tests.common import TransactionComponentCase + + +class TestReplaceFile(TransactionComponentCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.media = cls.env["storage.media"].create( + {"name": "old.txt", "data": base64.b64encode(b"old")} + ) + cls.product = cls.env["product.product"].create({"name": "Media product"}) + cls.category = cls.env["product.category"].create( + {"name": "Media categ", "media_ids": [(4, cls.media.id)]} + ) + cls.relation = cls.env["product.media.relation"].create( + { + "media_id": cls.media.id, + "product_tmpl_id": cls.product.product_tmpl_id.id, + } + ) + + def _replace_media_file(self, keep_history, keep_relations=False): + wiz_form = Form( + self.env["storage.file.replace"].with_context( + active_model="storage.media", active_id=self.media.id + ), + view="storage_media.storage_file_replace_view_form", + ) + wiz_form.file_name = "new.txt" + wiz_form.data = base64.b64encode(b"new") + wiz_form.keep_history = keep_history + if keep_history: + wiz_form.keep_relations = keep_relations + wiz_form.save().confirm() + + def test_replace_keep_history_and_relations(self): + old_file = self.media.file_id + self._replace_media_file(keep_history=True, keep_relations=True) + # The relations of the archived media are hidden by default + product_tmpl = self.product.product_tmpl_id.with_context(active_test=False) + self.assertEqual(len(product_tmpl.media_ids), 2) + archived_relation = product_tmpl.media_ids - self.relation + self.assertFalse(archived_relation.active) + self.assertEqual(archived_relation.media_id.file_id, old_file) + self.assertTrue(self.relation.active) + self.assertNotEqual(self.relation.media_id.file_id, old_file) + self.assertEqual( + self.category.with_context(active_test=False).media_ids, + self.media | archived_relation.media_id, + ) + + def test_replace_keep_history_only(self): + self._replace_media_file(keep_history=True) + product_tmpl = self.product.product_tmpl_id.with_context(active_test=False) + self.assertEqual(product_tmpl.media_ids, self.relation) + self.assertEqual( + self.category.with_context(active_test=False).media_ids, self.media + ) + + def test_replace_no_history(self): + self._replace_media_file(keep_history=False) + product_tmpl = self.product.product_tmpl_id.with_context(active_test=False) + self.assertEqual(product_tmpl.media_ids, self.relation) + + def test_keep_relations_visibility(self): + wiz_form = Form( + self.env["storage.file.replace"].with_context( + active_model="storage.media", active_id=self.media.id + ), + view="storage_media.storage_file_replace_view_form", + ) + self.assertTrue(wiz_form.keep_relations_visible) + wiz_form.keep_history = False + self.assertFalse(wiz_form.keep_relations_visible) diff --git a/storage_media_product/wizards/__init__.py b/storage_media_product/wizards/__init__.py new file mode 100644 index 0000000000..3993be0f08 --- /dev/null +++ b/storage_media_product/wizards/__init__.py @@ -0,0 +1 @@ +from . import replace_file diff --git a/storage_media_product/wizards/replace_file.py b/storage_media_product/wizards/replace_file.py new file mode 100644 index 0000000000..9033e78ea5 --- /dev/null +++ b/storage_media_product/wizards/replace_file.py @@ -0,0 +1,30 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import fields, models + + +class StorageFileReplace(models.TransientModel): + _inherit = "storage.file.replace" + + def _has_replaced_relations(self): + return super()._has_replaced_relations() or bool(self.media_id) + + def _copy_replaced_relations(self, owner, archived): + res = super()._copy_replaced_relations(owner, archived) + if owner._name == "storage.media": + self._copy_replaced_media_relations(owner, archived) + return res + + def _copy_replaced_media_relations(self, media, archived_media): + relations = ( + self.env["product.media.relation"] + .sudo() + .with_context(active_test=False) + .search([("media_id", "=", media.id)]) + ) + relations.copy({"media_id": archived_media.id}) + categories = ( + self.env["product.category"].sudo().search([("media_ids", "in", media.ids)]) + ) + categories.write({"media_ids": [fields.Command.link(archived_media.id)]}) From cc126cb0bca8cce97cbeed99b4412fc435f791f1 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 1 Oct 2026 17:25:58 +0200 Subject: [PATCH 7/7] [IMP] storage_image_product: keep relations on replace On file replacement, copy the product and category relations of the image to its archived copy when 'keep_relations' is set. --- storage_image_product/__init__.py | 1 + storage_image_product/tests/__init__.py | 1 + .../tests/test_replace_file.py | 77 +++++++++++++++++++ storage_image_product/wizards/__init__.py | 1 + storage_image_product/wizards/replace_file.py | 27 +++++++ 5 files changed, 107 insertions(+) create mode 100644 storage_image_product/tests/test_replace_file.py create mode 100644 storage_image_product/wizards/__init__.py create mode 100644 storage_image_product/wizards/replace_file.py diff --git a/storage_image_product/__init__.py b/storage_image_product/__init__.py index 0650744f6b..aee8895e7a 100644 --- a/storage_image_product/__init__.py +++ b/storage_image_product/__init__.py @@ -1 +1,2 @@ from . import models +from . import wizards diff --git a/storage_image_product/tests/__init__.py b/storage_image_product/tests/__init__.py index b2a98e821b..f31461e0f4 100644 --- a/storage_image_product/tests/__init__.py +++ b/storage_image_product/tests/__init__.py @@ -1 +1,2 @@ from . import test_product_image_relation +from . import test_replace_file diff --git a/storage_image_product/tests/test_replace_file.py b/storage_image_product/tests/test_replace_file.py new file mode 100644 index 0000000000..2efd637867 --- /dev/null +++ b/storage_image_product/tests/test_replace_file.py @@ -0,0 +1,77 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.tests import Form + +from .common import ProductImageCaseMixin, TransactionComponentCase + + +class TestReplaceFile(TransactionComponentCase, ProductImageCaseMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.image = cls._create_storage_image("logo-image.jpg") + cls.template = cls.env["product.template"].create({"name": "Image product"}) + cls.category = cls.env["product.category"].create({"name": "Image categ"}) + cls.product_relation = cls.env["product.image.relation"].create( + {"image_id": cls.image.id, "product_tmpl_id": cls.template.id} + ) + cls.category_relation = cls.env["category.image.relation"].create( + {"image_id": cls.image.id, "category_id": cls.category.id} + ) + + def _replace_image_file(self, keep_history, keep_relations=False): + wiz_form = Form( + self.env["storage.file.replace"].with_context( + active_model="storage.image", active_id=self.image.id + ), + view="storage_image.storage_file_replace_view_form", + ) + wiz_form.file_name = "white-image.jpg" + wiz_form.data = self._get_file_content("white-image.jpg") + wiz_form.keep_history = keep_history + if keep_history: + wiz_form.keep_relations = keep_relations + wiz_form.save().confirm() + + def _relations(self, model): + images = self.image | self._archived_image() + return ( + self.env[model] + .with_context(active_test=False) + .search([("image_id", "in", images.ids)]) + ) + + def _archived_image(self): + return self.env["storage.image"].search( + [("file_id", "=", self.old_file.id), ("active", "=", False)] + ) + + def test_replace_keep_history_and_relations(self): + self.old_file = self.image.file_id + self._replace_image_file(keep_history=True, keep_relations=True) + archived_image = self._archived_image() + self.assertEqual(len(archived_image), 1) + self.assertTrue(self.image.active) + self.assertNotEqual(self.image.file_id, self.old_file) + for model, current in ( + ("product.image.relation", self.product_relation), + ("category.image.relation", self.category_relation), + ): + archived_relation = self._relations(model) - current + self.assertEqual(archived_relation.image_id, archived_image) + self.assertFalse(archived_relation.active) + # The relations of the archived image are hidden on the product + self.assertEqual(self.template.image_ids, self.product_relation) + self.assertEqual(self.template.main_image_id, self.image) + + def test_replace_keep_history_only(self): + self.old_file = self.image.file_id + self._replace_image_file(keep_history=True) + self.assertEqual(len(self._archived_image()), 1) + self.assertEqual( + self._relations("product.image.relation"), self.product_relation + ) + self.assertEqual( + self._relations("category.image.relation"), self.category_relation + ) diff --git a/storage_image_product/wizards/__init__.py b/storage_image_product/wizards/__init__.py new file mode 100644 index 0000000000..3993be0f08 --- /dev/null +++ b/storage_image_product/wizards/__init__.py @@ -0,0 +1 @@ +from . import replace_file diff --git a/storage_image_product/wizards/replace_file.py b/storage_image_product/wizards/replace_file.py new file mode 100644 index 0000000000..bfdec65f86 --- /dev/null +++ b/storage_image_product/wizards/replace_file.py @@ -0,0 +1,27 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import models + + +class StorageFileReplace(models.TransientModel): + _inherit = "storage.file.replace" + + def _has_replaced_relations(self): + return super()._has_replaced_relations() or bool(self.image_id) + + def _copy_replaced_relations(self, owner, archived): + res = super()._copy_replaced_relations(owner, archived) + if owner._name == "storage.image": + self._copy_replaced_image_relations(owner, archived) + return res + + def _copy_replaced_image_relations(self, image, archived_image): + for model in ("product.image.relation", "category.image.relation"): + relations = ( + self.env[model] + .sudo() + .with_context(active_test=False) + .search([("image_id", "=", image.id)]) + ) + relations.copy({"image_id": archived_image.id})