Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions database_cleanup/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -88,6 +88,9 @@ Contributors
* Mark Schuit <mark@gig.solutions>
* `360ERP <https://www.360erp.com>`_:
* Andrea Stirpe
* `Cetmix <https://cetmix.com/>`_:
* Ivan Sokolov
* George Smirnov

Maintainers
~~~~~~~~~~~
Expand Down
1 change: 1 addition & 0 deletions database_cleanup/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
Expand Down
1 change: 1 addition & 0 deletions database_cleanup/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 110 additions & 0 deletions database_cleanup/models/purge_attachments.py
Original file line number Diff line number Diff line change
@@ -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),
)
Comment thread
StefanRijnhart marked this conversation as resolved.
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")
3 changes: 3 additions & 0 deletions database_cleanup/readme/CONTRIBUTORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
* Mark Schuit <mark@gig.solutions>
* `360ERP <https://www.360erp.com>`_:
* Andrea Stirpe
* `Cetmix <https://cetmix.com/>`_:
* Ivan Sokolov
* George Smirnov
4 changes: 2 additions & 2 deletions database_cleanup/readme/DESCRIPTION.rst
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion database_cleanup/readme/USAGE.rst
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
2 changes: 2 additions & 0 deletions database_cleanup/security/ir.model.access.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 6 additions & 3 deletions database_cleanup/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -375,8 +375,8 @@ <h1>Database cleanup</h1>
!! source digest: sha256:f2204c1d994e5d6dc3dc0ee765ef5b3d735612c05c4a8d467bab9d9bedd99872
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/license-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/server-tools/tree/16.0/database_cleanup"><img alt="OCA/server-tools" src="https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/server-tools-16-0/server-tools-16-0-database_cleanup"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/server-tools&amp;target_branch=16.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>Clean your Odoo database from remnants of modules, models, columns and
tables left by uninstalled modules (prior to 7.0) or a homebrew database
<p>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.</p>
<p>Caution! This module is potentially harmful and can <em>easily</em> destroy the
integrity of your data. Do not use if you are not entirely comfortable
Expand All @@ -400,7 +400,7 @@ <h1>Database cleanup</h1>
<h2><a class="toc-backref" href="#toc-entry-1">Usage</a></h2>
<p>After installation of this module, go to the Settings menu -&gt; Technical -&gt;
Database cleanup. This menu is only available to members of the <em>Access Rights</em>
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 <em>really</em> confident).</p>
Expand Down Expand Up @@ -439,6 +439,9 @@ <h3><a class="toc-backref" href="#toc-entry-5">Contributors</a></h3>
</dd>
</dl>
</li>
<li><a class="reference external" href="https://cetmix.com/">Cetmix</a>:
* Ivan Sokolov
* George Smirnov</li>
</ul>
</div>
<div class="section" id="maintainers">
Expand Down
1 change: 1 addition & 0 deletions database_cleanup/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
153 changes: 153 additions & 0 deletions database_cleanup/tests/test_purge_attachments.py
Original file line number Diff line number Diff line change
@@ -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):
Comment thread
StefanRijnhart marked this conversation as resolved.
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())
7 changes: 7 additions & 0 deletions database_cleanup/views/menu.xml
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,11 @@
<field name="action" ref="action_purge_property" />
<field name="parent_id" ref="menu_database_cleanup" />
</record>

<record model="ir.ui.menu" id="menu_purge_attachments">
<field name="name">Purge orphaned attachments</field>
<field name="sequence" eval="85" />
<field name="action" ref="action_purge_attachments" />
<field name="parent_id" ref="menu_database_cleanup" />
</record>
</odoo>
Loading
Loading