diff --git a/database_cleanup/README.rst b/database_cleanup/README.rst index 61869c49a6b..29988928c8a 100644 --- a/database_cleanup/README.rst +++ b/database_cleanup/README.rst @@ -32,8 +32,8 @@ Database cleanup |badge1| |badge2| |badge3| |badge4| |badge5| -Clean your Odoo database from remnants of modules, models, columns and -tables left by uninstalled modules (prior to 7.0) or a homebrew database +Clean your Odoo database from remnants of modules, models, columns, tables and +attachment records left by uninstalled modules (prior to 7.0) or a homebrew database upgrade to a new major version of Odoo. Caution! This module is potentially harmful and can *easily* destroy the @@ -52,7 +52,7 @@ Usage After installation of this module, go to the Settings menu -> Technical -> Database cleanup. This menu is only available to members of the *Access Rights* -group. Go through the modules, models, columns and tables +group. Go through the modules, models, columns, tables and attachment entries under this menu (in that order) and find out if there is orphaned data in your database. You can either delete entries by line, or sweep all entries in one big step (if you are *really* confident). @@ -88,6 +88,9 @@ Contributors * Mark Schuit * `360ERP `_: * Andrea Stirpe +* `Cetmix `_: + * Ivan Sokolov + * George Smirnov Maintainers ~~~~~~~~~~~ diff --git a/database_cleanup/__manifest__.py b/database_cleanup/__manifest__.py index 2bb21d2ef61..87024a6c75c 100644 --- a/database_cleanup/__manifest__.py +++ b/database_cleanup/__manifest__.py @@ -20,6 +20,7 @@ "views/purge_data.xml", "views/create_indexes.xml", "views/purge_properties.xml", + "views/purge_attachments.xml", "views/menu.xml", "security/ir.model.access.csv", ], diff --git a/database_cleanup/models/__init__.py b/database_cleanup/models/__init__.py index a7685efdb47..ab74bfe2d06 100644 --- a/database_cleanup/models/__init__.py +++ b/database_cleanup/models/__init__.py @@ -4,6 +4,7 @@ from . import purge_fields from . import purge_columns from . import purge_tables +from . import purge_attachments from . import purge_data from . import purge_menus from . import create_indexes diff --git a/database_cleanup/models/purge_attachments.py b/database_cleanup/models/purge_attachments.py new file mode 100644 index 00000000000..b862b55f9ac --- /dev/null +++ b/database_cleanup/models/purge_attachments.py @@ -0,0 +1,110 @@ +# Copyright 2026 Cetmix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import os + +from odoo import _, api, fields, models +from odoo.exceptions import AccessError, UserError, ValidationError + +REASON_MISSING_FILE = "missing_file" + + +class CleanupPurgeLineAttachment(models.TransientModel): + _inherit = "cleanup.purge.line" + _name = "cleanup.purge.line.attachment" + _description = "Cleanup Purge Line Attachment" + + attachment_id = fields.Many2one("ir.attachment") + reason = fields.Selection( + [ + (REASON_MISSING_FILE, "File missing in filestore"), + ], + ) + error_message = fields.Char(readonly=True) + wizard_id = fields.Many2one("cleanup.purge.wizard.attachment", readonly=True) + + def purge(self): + """Unlink orphaned attachment records upon manual confirmation. + + Filters unpurged lines with attachment_id. Unlinks each attachment + individually; failures are logged and skipped so the batch continues. + Only successfully removed attachments get their lines marked purged. + + :return: result of write({"purged": True}) on successfully purged lines, + or True if none were purged + """ + if self: + objs = self + else: + objs = self.env["cleanup.purge.line.attachment"].browse( + self._context.get("active_ids") + ) + to_unlink = objs.filtered(lambda x: not x.purged and x.attachment_id) + self.logger.info("Purging attachments: %s", to_unlink.mapped("name")) + purged_line_ids = [] + for line in to_unlink: + attach = line.attachment_id + try: + attach.unlink() + purged_line_ids.append(line.id) + except (UserError, ValidationError, AccessError) as exc: + self.logger.warning( + "Attachment #%s cannot be deleted: %s", + attach.id, + str(exc), + ) + line.error_message = str(exc) + if not purged_line_ids: + return True + return ( + self.env["cleanup.purge.line.attachment"] + .browse(purged_line_ids) + .write({"purged": True}) + ) + + +class CleanupPurgeWizardAttachment(models.TransientModel): + _inherit = "cleanup.purge.wizard" + _name = "cleanup.purge.wizard.attachment" + _description = "Purge attachments" + + @api.model + def find(self): + """Collect ir.attachment records whose backing files are missing on disk. + + Requires file storage. Searches binary attachments with store_fname, + checks each file exists via os.path.isfile(_full_path(store_fname)). + + :raises UserError: if storage != "file" or no orphaned entries found + """ + if self.env["ir.attachment"]._storage() != "file": + raise UserError( + _( + "Attachment storage is not 'file'. " + "Purge of orphaned attachments only works with file storage." + ) + ) + res = [] + attachments = self.env["ir.attachment"].search( + [ + ("store_fname", "!=", False), + ("type", "=", "binary"), + ] + ) + for attach in attachments: + full_path = self.env["ir.attachment"]._full_path(attach.store_fname) + if not os.path.isfile(full_path): + res.append( + fields.Command.create( + { + "attachment_id": attach.id, + "name": attach.store_fname or attach.name or str(attach.id), + "reason": REASON_MISSING_FILE, + } + ) + ) + if not res: + raise UserError(_("No orphaned attachment entries found")) + return res + + purge_line_ids = fields.One2many("cleanup.purge.line.attachment", "wizard_id") diff --git a/database_cleanup/readme/CONTRIBUTORS.rst b/database_cleanup/readme/CONTRIBUTORS.rst index 2484a38beda..a11000ec9ec 100644 --- a/database_cleanup/readme/CONTRIBUTORS.rst +++ b/database_cleanup/readme/CONTRIBUTORS.rst @@ -4,3 +4,6 @@ * Mark Schuit * `360ERP `_: * Andrea Stirpe +* `Cetmix `_: + * Ivan Sokolov + * George Smirnov diff --git a/database_cleanup/readme/DESCRIPTION.rst b/database_cleanup/readme/DESCRIPTION.rst index 1656dca9721..bfe78f9c74b 100644 --- a/database_cleanup/readme/DESCRIPTION.rst +++ b/database_cleanup/readme/DESCRIPTION.rst @@ -1,5 +1,5 @@ -Clean your Odoo database from remnants of modules, models, columns and -tables left by uninstalled modules (prior to 7.0) or a homebrew database +Clean your Odoo database from remnants of modules, models, columns, tables and +attachment records left by uninstalled modules (prior to 7.0) or a homebrew database upgrade to a new major version of Odoo. Caution! This module is potentially harmful and can *easily* destroy the diff --git a/database_cleanup/readme/USAGE.rst b/database_cleanup/readme/USAGE.rst index f97ea497393..bb5d312dfcf 100644 --- a/database_cleanup/readme/USAGE.rst +++ b/database_cleanup/readme/USAGE.rst @@ -1,6 +1,6 @@ After installation of this module, go to the Settings menu -> Technical -> Database cleanup. This menu is only available to members of the *Access Rights* -group. Go through the modules, models, columns and tables +group. Go through the modules, models, columns, tables and attachment entries under this menu (in that order) and find out if there is orphaned data in your database. You can either delete entries by line, or sweep all entries in one big step (if you are *really* confident). diff --git a/database_cleanup/security/ir.model.access.csv b/database_cleanup/security/ir.model.access.csv index c70c420e9d3..0d92bc07aa0 100644 --- a/database_cleanup/security/ir.model.access.csv +++ b/database_cleanup/security/ir.model.access.csv @@ -17,3 +17,5 @@ access_cleanup_purge_line_menu,access_cleanup_purge_line_menu,model_cleanup_purg access_cleanup_purge_wizard_menu,access_cleanup_purge_wizard_menu,model_cleanup_purge_wizard_menu,base.group_user,1,1,1,1 access_cleanup_purge_line_property,access_cleanup_purge_line_property,model_cleanup_purge_line_property,base.group_user,1,1,1,1 access_cleanup_purge_wizard_property,access_cleanup_purge_wizard_property,model_cleanup_purge_wizard_property,base.group_user,1,1,1,1 +access_cleanup_purge_line_attachment,access_cleanup_purge_line_attachment,model_cleanup_purge_line_attachment,base.group_user,1,1,1,1 +access_cleanup_purge_wizard_attachment,access_cleanup_purge_wizard_attachment,model_cleanup_purge_wizard_attachment,base.group_user,1,1,1,1 diff --git a/database_cleanup/static/description/index.html b/database_cleanup/static/description/index.html index 0a4519e5b8a..e72cf18fcbc 100644 --- a/database_cleanup/static/description/index.html +++ b/database_cleanup/static/description/index.html @@ -375,8 +375,8 @@

Database cleanup

!! source digest: sha256:f2204c1d994e5d6dc3dc0ee765ef5b3d735612c05c4a8d467bab9d9bedd99872 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Beta License: AGPL-3 OCA/server-tools Translate me on Weblate Try me on Runboat

-

Clean your Odoo database from remnants of modules, models, columns and -tables left by uninstalled modules (prior to 7.0) or a homebrew database +

Clean your Odoo database from remnants of modules, models, columns, tables and +attachment records left by uninstalled modules (prior to 7.0) or a homebrew database upgrade to a new major version of Odoo.

Caution! This module is potentially harmful and can easily destroy the integrity of your data. Do not use if you are not entirely comfortable @@ -400,7 +400,7 @@

Database cleanup

Usage

After installation of this module, go to the Settings menu -> Technical -> Database cleanup. This menu is only available to members of the Access Rights -group. Go through the modules, models, columns and tables +group. Go through the modules, models, columns, tables and attachment entries under this menu (in that order) and find out if there is orphaned data in your database. You can either delete entries by line, or sweep all entries in one big step (if you are really confident).

@@ -439,6 +439,9 @@

Contributors

+
  • Cetmix: +* Ivan Sokolov +* George Smirnov
  • diff --git a/database_cleanup/tests/__init__.py b/database_cleanup/tests/__init__.py index 221c556007e..72ac379f314 100644 --- a/database_cleanup/tests/__init__.py +++ b/database_cleanup/tests/__init__.py @@ -4,6 +4,7 @@ from . import common from . import test_create_indexes from . import test_identifier_adapter +from . import test_purge_attachments from . import test_purge_columns from . import test_purge_data from . import test_purge_fields diff --git a/database_cleanup/tests/test_purge_attachments.py b/database_cleanup/tests/test_purge_attachments.py new file mode 100644 index 00000000000..348ee7659c0 --- /dev/null +++ b/database_cleanup/tests/test_purge_attachments.py @@ -0,0 +1,153 @@ +# Copyright 2026 Cetmix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import base64 +import os +from unittest.mock import patch + +from odoo.exceptions import UserError +from odoo.fields import Command +from odoo.tests.common import tagged + +from .common import Common, environment + + +# Use post_install to get all models loaded more info: odoo/odoo#13458 +@tagged("post_install", "-at_install") +class TestCleanupPurgeLineAttachment(Common): + def setUp(self): + """Create two ir.attachment records; delete backing file of one (orphan). + + :var orphan: ir.attachment with backing file removed via os.unlink + :var valid: ir.attachment with file intact + :return: None + """ + super().setUp() + with environment() as env: + IrAttachment = env["ir.attachment"] + datas = base64.b64encode(b"test_orphan").decode("ascii") + orphan = IrAttachment.create( + { + "name": "test_orphan_attachment.txt", + "type": "binary", + "datas": datas, + } + ) + datas_valid = base64.b64encode(b"test_valid").decode("ascii") + valid = IrAttachment.create( + { + "name": "test_valid_attachment.txt", + "type": "binary", + "datas": datas_valid, + } + ) + # Delete backing file to create orphan + full_path = IrAttachment._full_path(orphan.store_fname) + os.unlink(full_path) + self.orphan_attach_id = orphan.id + self.valid_attach_id = valid.id + + def test_find_orphaned_attachments(self): + """Assert wizard find() includes orphan in purge lines, excludes valid. + + :var wizard: cleanup.purge.wizard.attachment + :var line_attachment_ids: ids from purge_line_ids.mapped("attachment_id") + :return: None + """ + with environment() as env: + wizard = env["cleanup.purge.wizard.attachment"].create({}) + line_attachment_ids = wizard.purge_line_ids.mapped("attachment_id").ids + self.assertIn(self.orphan_attach_id, line_attachment_ids) + self.assertNotIn(self.valid_attach_id, line_attachment_ids) + + def test_purge_orphaned_attachments(self): + """Assert purge_all() removes orphan record, leaves valid intact. + + :var wizard: cleanup.purge.wizard.attachment + :var orphan: ir.attachment browsed by self.orphan_attach_id + :var valid: ir.attachment browsed by self.valid_attach_id + :return: None + """ + with environment() as env: + wizard = env["cleanup.purge.wizard.attachment"].create({}) + wizard.purge_all() + orphan = env["ir.attachment"].browse(self.orphan_attach_id) + valid = env["ir.attachment"].browse(self.valid_attach_id) + self.assertFalse(orphan.exists()) + self.assertTrue(valid.exists()) + + def test_purge_skips_protected_attachment(self): + """When unlink raises UserError on one line, purge others and skip. + + :var wizard: cleanup.purge.wizard.attachment + :return: None + """ + with environment() as env: + IrAttachment = env["ir.attachment"] + datas_a = base64.b64encode(b"test_protected").decode("ascii") + orphan_protected = IrAttachment.create( + { + "name": "test_protected_orphan.txt", + "type": "binary", + "datas": datas_a, + } + ) + datas_b = base64.b64encode(b"test_unprotected").decode("ascii") + orphan_other = IrAttachment.create( + { + "name": "test_unprotected_orphan.txt", + "type": "binary", + "datas": datas_b, + } + ) + os.unlink(IrAttachment._full_path(orphan_protected.store_fname)) + os.unlink(IrAttachment._full_path(orphan_other.store_fname)) + + protected_id = orphan_protected.id + other_id = orphan_other.id + + IrModel = env.registry["ir.attachment"] + original_unlink = IrModel.unlink + + def patched_unlink(self): + if protected_id in self.ids: + # Dynamic message avoids translation lint on test-only UserError. + raise UserError(str(protected_id)) + return original_unlink(self) + + wizard = env["cleanup.purge.wizard.attachment"].create( + { + "purge_line_ids": [ + Command.create( + { + "attachment_id": protected_id, + "name": orphan_protected.store_fname + or str(protected_id), + } + ), + Command.create( + { + "attachment_id": other_id, + "name": orphan_other.store_fname or str(other_id), + } + ), + ], + } + ) + protected_line = wizard.purge_line_ids.filtered( + lambda l: l.attachment_id.id == protected_id + ) + other_line = wizard.purge_line_ids.filtered( + lambda l: l.attachment_id.id == other_id + ) + protected_line_id = protected_line.id + other_line_id = other_line.id + + with patch.object(IrModel, "unlink", patched_unlink): + wizard.purge_line_ids.purge() + + Line = env["cleanup.purge.line.attachment"] + self.assertFalse(Line.browse(protected_line_id).purged) + self.assertTrue(Line.browse(other_line_id).purged) + self.assertTrue(IrAttachment.browse(protected_id).exists()) + self.assertFalse(IrAttachment.browse(other_id).exists()) diff --git a/database_cleanup/views/menu.xml b/database_cleanup/views/menu.xml index 353288c7156..13d4c80e408 100644 --- a/database_cleanup/views/menu.xml +++ b/database_cleanup/views/menu.xml @@ -70,4 +70,11 @@ + + + Purge orphaned attachments + + + + diff --git a/database_cleanup/views/purge_attachments.xml b/database_cleanup/views/purge_attachments.xml new file mode 100644 index 00000000000..d43714d60e6 --- /dev/null +++ b/database_cleanup/views/purge_attachments.xml @@ -0,0 +1,55 @@ + + + + cleanup.purge.wizard.attachment + + primary + + + + + + + + + + Purge orphaned attachments + ir.actions.server + code + + + action = env.get('cleanup.purge.wizard.attachment').get_wizard_action() + + + + + cleanup.purge.line.attachment + + primary + + + + + + + + + + + Purge + ir.actions.server + code + + records.purge() + + +