Skip to content

Commit 699d8f0

Browse files
committed
[IMP] database_cleanup: purge orphaned attachments
Add wizard to list ir.attachment records whose store_fname points to a missing file on disk when storage is "file", and allow purging those rows. task 5258
1 parent 42042e7 commit 699d8f0

12 files changed

Lines changed: 250 additions & 20 deletions

File tree

database_cleanup/README.rst

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
.. image:: https://odoo-community.org/readme-banner-image
2+
:target: https://odoo-community.org/get-involved?utm_source=readme
3+
:alt: Odoo Community Association
4+
15
================
26
Database cleanup
37
================
@@ -13,7 +17,7 @@ Database cleanup
1317
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
1418
:target: https://odoo-community.org/page/development-status
1519
:alt: Beta
16-
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
20+
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
1721
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
1822
:alt: License: AGPL-3
1923
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github
@@ -28,8 +32,8 @@ Database cleanup
2832

2933
|badge1| |badge2| |badge3| |badge4| |badge5|
3034

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

3539
Caution! This module is potentially harmful and can *easily* destroy the
@@ -48,7 +52,7 @@ Usage
4852

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

database_cleanup/__manifest__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"views/purge_data.xml",
2121
"views/create_indexes.xml",
2222
"views/purge_properties.xml",
23+
"views/purge_attachments.xml",
2324
"views/menu.xml",
2425
"security/ir.model.access.csv",
2526
],

database_cleanup/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from . import purge_fields
55
from . import purge_columns
66
from . import purge_tables
7+
from . import purge_attachments
78
from . import purge_data
89
from . import purge_menus
910
from . import create_indexes
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Copyright 2026 Cetmix
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
3+
4+
import os
5+
6+
from odoo import _, api, fields, models
7+
from odoo.exceptions import UserError
8+
9+
10+
class CleanupPurgeLineAttachment(models.TransientModel):
11+
_inherit = "cleanup.purge.line"
12+
_name = "cleanup.purge.line.attachment"
13+
_description = "Cleanup Purge Line Attachment"
14+
15+
attachment_id = fields.Many2one("ir.attachment")
16+
wizard_id = fields.Many2one("cleanup.purge.wizard.attachment", readonly=True)
17+
18+
def purge(self):
19+
"""Unlink orphaned attachment records upon manual confirmation.
20+
21+
Filters unpurged lines with attachment_id, unlinks their ir.attachment
22+
records, and marks lines as purged.
23+
:return: result of to_unlink.write({"purged": True})
24+
"""
25+
if self:
26+
objs = self
27+
else:
28+
objs = self.env["cleanup.purge.line.attachment"].browse(
29+
self._context.get("active_ids")
30+
)
31+
to_unlink = objs.filtered(lambda x: not x.purged and x.attachment_id)
32+
self.logger.info("Purging attachments: %s", to_unlink.mapped("name"))
33+
to_unlink.mapped("attachment_id").unlink()
34+
return to_unlink.write({"purged": True})
35+
36+
37+
class CleanupPurgeWizardAttachment(models.TransientModel):
38+
_inherit = "cleanup.purge.wizard"
39+
_name = "cleanup.purge.wizard.attachment"
40+
_description = "Purge attachments"
41+
42+
@api.model
43+
def find(self):
44+
"""Collect ir.attachment records whose backing files are missing on disk.
45+
46+
Requires file storage. Searches binary attachments with store_fname,
47+
checks each file exists via os.path.isfile(_full_path(store_fname)).
48+
49+
:raises UserError: if storage != "file" or no orphaned entries found
50+
"""
51+
if self.env["ir.attachment"]._storage() != "file":
52+
raise UserError(
53+
_(
54+
"Attachment storage is not 'file'. "
55+
"Purge of orphaned attachments only works with file storage."
56+
)
57+
)
58+
res = []
59+
attachments = self.env["ir.attachment"].search(
60+
[
61+
("store_fname", "!=", False),
62+
("type", "=", "binary"),
63+
]
64+
)
65+
for attach in attachments:
66+
full_path = self.env["ir.attachment"]._full_path(attach.store_fname)
67+
if not os.path.isfile(full_path):
68+
res.append(
69+
(
70+
0,
71+
0,
72+
{
73+
"attachment_id": attach.id,
74+
"name": attach.store_fname or attach.name or str(attach.id),
75+
},
76+
)
77+
)
78+
if not res:
79+
raise UserError(_("No orphaned attachment entries found"))
80+
return res
81+
82+
purge_line_ids = fields.One2many("cleanup.purge.line.attachment", "wizard_id")

database_cleanup/readme/DESCRIPTION.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
Clean your Odoo database from remnants of modules, models, columns and
2-
tables left by uninstalled modules (prior to 7.0) or a homebrew database
1+
Clean your Odoo database from remnants of modules, models, columns, tables and
2+
attachment records left by uninstalled modules (prior to 7.0) or a homebrew database
33
upgrade to a new major version of Odoo.
44

55
Caution! This module is potentially harmful and can *easily* destroy the

database_cleanup/readme/USAGE.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
After installation of this module, go to the Settings menu -> Technical ->
22
Database cleanup. This menu is only available to members of the *Access Rights*
3-
group. Go through the modules, models, columns and tables
3+
group. Go through the modules, models, columns, tables and attachment
44
entries under this menu (in that order) and find out if there is orphaned data
55
in your database. You can either delete entries by line, or sweep all entries
66
in one big step (if you are *really* confident).

database_cleanup/security/ir.model.access.csv

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ access_cleanup_purge_line_menu,access_cleanup_purge_line_menu,model_cleanup_purg
1717
access_cleanup_purge_wizard_menu,access_cleanup_purge_wizard_menu,model_cleanup_purge_wizard_menu,base.group_user,1,1,1,1
1818
access_cleanup_purge_line_property,access_cleanup_purge_line_property,model_cleanup_purge_line_property,base.group_user,1,1,1,1
1919
access_cleanup_purge_wizard_property,access_cleanup_purge_wizard_property,model_cleanup_purge_wizard_property,base.group_user,1,1,1,1
20+
access_cleanup_purge_line_attachment,access_cleanup_purge_line_attachment,model_cleanup_purge_line_attachment,base.group_user,1,1,1,1
21+
access_cleanup_purge_wizard_attachment,access_cleanup_purge_wizard_attachment,model_cleanup_purge_wizard_attachment,base.group_user,1,1,1,1

database_cleanup/static/description/index.html

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<head>
44
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
55
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
6-
<title>Database cleanup</title>
6+
<title>README.rst</title>
77
<style type="text/css">
88

99
/*
@@ -360,18 +360,23 @@
360360
</style>
361361
</head>
362362
<body>
363-
<div class="document" id="database-cleanup">
364-
<h1 class="title">Database cleanup</h1>
363+
<div class="document">
365364

365+
366+
<a class="reference external image-reference" href="https://odoo-community.org/get-involved?utm_source=readme">
367+
<img alt="Odoo Community Association" src="https://odoo-community.org/readme-banner-image" />
368+
</a>
369+
<div class="section" id="database-cleanup">
370+
<h1>Database cleanup</h1>
366371
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
367372
!! This file is generated by oca-gen-addon-readme !!
368373
!! changes will be overwritten. !!
369374
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
370375
!! source digest: sha256:8e972dd8d647163c6d93e026514d854ced08bbe13ac2c39e3a7c36202e1aa328
371376
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
372-
<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/licence-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>
373-
<p>Clean your Odoo database from remnants of modules, models, columns and
374-
tables left by uninstalled modules (prior to 7.0) or a homebrew database
377+
<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>
378+
<p>Clean your Odoo database from remnants of modules, models, columns, tables and
379+
attachment records left by uninstalled modules (prior to 7.0) or a homebrew database
375380
upgrade to a new major version of Odoo.</p>
376381
<p>Caution! This module is potentially harmful and can <em>easily</em> destroy the
377382
integrity of your data. Do not use if you are not entirely comfortable
@@ -392,10 +397,10 @@ <h1 class="title">Database cleanup</h1>
392397
</ul>
393398
</div>
394399
<div class="section" id="usage">
395-
<h1><a class="toc-backref" href="#toc-entry-1">Usage</a></h1>
400+
<h2><a class="toc-backref" href="#toc-entry-1">Usage</a></h2>
396401
<p>After installation of this module, go to the Settings menu -&gt; Technical -&gt;
397402
Database cleanup. This menu is only available to members of the <em>Access Rights</em>
398-
group. Go through the modules, models, columns and tables
403+
group. Go through the modules, models, columns, tables and attachment
399404
entries under this menu (in that order) and find out if there is orphaned data
400405
in your database. You can either delete entries by line, or sweep all entries
401406
in one big step (if you are <em>really</em> confident).</p>
@@ -404,23 +409,23 @@ <h1><a class="toc-backref" href="#toc-entry-1">Usage</a></h1>
404409
</a>
405410
</div>
406411
<div class="section" id="bug-tracker">
407-
<h1><a class="toc-backref" href="#toc-entry-2">Bug Tracker</a></h1>
412+
<h2><a class="toc-backref" href="#toc-entry-2">Bug Tracker</a></h2>
408413
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/server-tools/issues">GitHub Issues</a>.
409414
In case of trouble, please check there if your issue has already been reported.
410415
If you spotted it first, help us to smash it by providing a detailed and welcomed
411416
<a class="reference external" href="https://github.com/OCA/server-tools/issues/new?body=module:%20database_cleanup%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
412417
<p>Do not contact contributors directly about support or help with technical issues.</p>
413418
</div>
414419
<div class="section" id="credits">
415-
<h1><a class="toc-backref" href="#toc-entry-3">Credits</a></h1>
420+
<h2><a class="toc-backref" href="#toc-entry-3">Credits</a></h2>
416421
<div class="section" id="authors">
417-
<h2><a class="toc-backref" href="#toc-entry-4">Authors</a></h2>
422+
<h3><a class="toc-backref" href="#toc-entry-4">Authors</a></h3>
418423
<ul class="simple">
419424
<li>Therp BV</li>
420425
</ul>
421426
</div>
422427
<div class="section" id="contributors">
423-
<h2><a class="toc-backref" href="#toc-entry-5">Contributors</a></h2>
428+
<h3><a class="toc-backref" href="#toc-entry-5">Contributors</a></h3>
424429
<ul class="simple">
425430
<li>Stefan Rijnhart &lt;<a class="reference external" href="mailto:stefan&#64;opener.amsterdam">stefan&#64;opener.amsterdam</a>&gt;</li>
426431
<li>Holger Brunn &lt;<a class="reference external" href="mailto:hbrunn&#64;therp.nl">hbrunn&#64;therp.nl</a>&gt;</li>
@@ -437,7 +442,7 @@ <h2><a class="toc-backref" href="#toc-entry-5">Contributors</a></h2>
437442
</ul>
438443
</div>
439444
<div class="section" id="maintainers">
440-
<h2><a class="toc-backref" href="#toc-entry-6">Maintainers</a></h2>
445+
<h3><a class="toc-backref" href="#toc-entry-6">Maintainers</a></h3>
441446
<p>This module is maintained by the OCA.</p>
442447
<a class="reference external image-reference" href="https://odoo-community.org">
443448
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />
@@ -450,5 +455,6 @@ <h2><a class="toc-backref" href="#toc-entry-6">Maintainers</a></h2>
450455
</div>
451456
</div>
452457
</div>
458+
</div>
453459
</body>
454460
</html>

database_cleanup/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from . import common
55
from . import test_create_indexes
66
from . import test_identifier_adapter
7+
from . import test_purge_attachments
78
from . import test_purge_columns
89
from . import test_purge_data
910
from . import test_purge_fields
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Copyright 2026 Cetmix
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
3+
4+
import base64
5+
import os
6+
7+
from odoo.tests.common import tagged
8+
9+
from .common import Common, environment
10+
11+
12+
# Use post_install to get all models loaded more info: odoo/odoo#13458
13+
@tagged("post_install", "-at_install")
14+
class TestCleanupPurgeLineAttachment(Common):
15+
def setUp(self):
16+
"""Create two ir.attachment records; delete backing file of one (orphan).
17+
18+
:var orphan: ir.attachment with backing file removed via os.unlink
19+
:var valid: ir.attachment with file intact
20+
:return: None
21+
"""
22+
super().setUp()
23+
with environment() as env:
24+
IrAttachment = env["ir.attachment"]
25+
datas = base64.b64encode(b"test_orphan").decode("ascii")
26+
orphan = IrAttachment.create(
27+
{
28+
"name": "test_orphan_attachment.txt",
29+
"type": "binary",
30+
"datas": datas,
31+
}
32+
)
33+
datas_valid = base64.b64encode(b"test_valid").decode("ascii")
34+
valid = IrAttachment.create(
35+
{
36+
"name": "test_valid_attachment.txt",
37+
"type": "binary",
38+
"datas": datas_valid,
39+
}
40+
)
41+
# Delete backing file to create orphan
42+
full_path = IrAttachment._full_path(orphan.store_fname)
43+
os.unlink(full_path)
44+
self.orphan_attach_id = orphan.id
45+
self.valid_attach_id = valid.id
46+
47+
def test_find_orphaned_attachments(self):
48+
"""Assert wizard find() includes orphan in purge lines, excludes valid.
49+
50+
:var wizard: cleanup.purge.wizard.attachment
51+
:var line_attachment_ids: ids from purge_line_ids.mapped("attachment_id")
52+
:return: None
53+
"""
54+
with environment() as env:
55+
wizard = env["cleanup.purge.wizard.attachment"].create({})
56+
line_attachment_ids = wizard.purge_line_ids.mapped("attachment_id").ids
57+
self.assertIn(self.orphan_attach_id, line_attachment_ids)
58+
self.assertNotIn(self.valid_attach_id, line_attachment_ids)
59+
60+
def test_purge_orphaned_attachments(self):
61+
"""Assert purge_all() removes orphan record, leaves valid intact.
62+
63+
:var wizard: cleanup.purge.wizard.attachment
64+
:var orphan: ir.attachment browsed by self.orphan_attach_id
65+
:var valid: ir.attachment browsed by self.valid_attach_id
66+
:return: None
67+
"""
68+
with environment() as env:
69+
wizard = env["cleanup.purge.wizard.attachment"].create({})
70+
wizard.purge_all()
71+
orphan = env["ir.attachment"].browse(self.orphan_attach_id)
72+
valid = env["ir.attachment"].browse(self.valid_attach_id)
73+
self.assertFalse(orphan.exists())
74+
self.assertTrue(valid.exists())

0 commit comments

Comments
 (0)