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/models/storage_file.py b/storage_file/models/storage_file.py
index f5b5282c12..995301275d 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})
@@ -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(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
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).
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
+
+
+
+
+
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)
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)}
+
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})
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
+
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)]})