-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
[16.0][IMP] database_cleanup: purge orphaned attachments #3566
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| # 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" | ||
| ATTACHMENT_FIND_BATCH = 5000 | ||
|
|
||
|
|
||
| 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): | ||
|
StefanRijnhart marked this conversation as resolved.
|
||
| _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 = [] | ||
| last_id = 0 | ||
| ir_attachment = self.env["ir.attachment"] | ||
| while True: | ||
| rows = ir_attachment.search_read( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. since the purpose of this change is to avoid MemoryError on very large databases, please evict the processed batch from the cache before fetching the next one using
|
||
| [ | ||
| ("id", ">", last_id), | ||
| ("store_fname", "!=", False), | ||
| ("type", "=", "binary"), | ||
| ], | ||
| ["store_fname", "name"], | ||
| limit=ATTACHMENT_FIND_BATCH, | ||
| order="id", | ||
| ) | ||
| if not rows: | ||
| break | ||
| batch_ids = [row["id"] for row in rows] | ||
| last_id = batch_ids[-1] | ||
| for row in rows: | ||
| full_path = ir_attachment._full_path(row["store_fname"]) | ||
| if not os.path.isfile(full_path): | ||
| res.append( | ||
| fields.Command.create( | ||
| { | ||
| "attachment_id": row["id"], | ||
| "name": row["store_fname"] | ||
| or row["name"] | ||
| or str(row["id"]), | ||
| "reason": REASON_MISSING_FILE, | ||
| } | ||
| ) | ||
| ) | ||
| ir_attachment.browse(batch_ids).invalidate_recordset( | ||
| ["store_fname", "name"] | ||
| ) | ||
| if not res: | ||
| raise UserError(_("No orphaned attachment entries found")) | ||
| return res | ||
|
|
||
| purge_line_ids = fields.One2many("cleanup.purge.line.attachment", "wizard_id") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,14 @@ | ||
| 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). | ||
|
|
||
| On databases with a very large number of attachments, opening | ||
| *Purge orphaned attachments* scans stored files in batches and may take | ||
| longer than the other cleanup wizards. | ||
|
|
||
| .. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas | ||
| :alt: Try me on Runbot | ||
| :target: https://runbot.odoo-community.org/runbot/149/11.0 |
Uh oh!
There was an error while loading. Please reload this page.