Skip to content

Commit 777c459

Browse files
committed
[FIX] fs_attachment: Fix attachment store after upgrade
Prerequisites: * The fs_attachment addon is installed, and specific filesystem storage is configured. * All the attachments are already stored in the appropriate filesystem storage. (We assume that if you've installed the addon on an existing database, you'vealready migrated the existing attachments appropriately. Context: * You run an upgrade command on your Odoo server. Problem: Some attachments could be created during the upgrade process before the load of the fs_attachment module or the configuration of the filesystem storage backends. This is especially true for the attachments created by addons to store the app icons or even attachments from XML data files. As a result, you end up with attachments.that could be found by some Odoo instances if, for example, you have multiple Odoo instances running on different servers since these attachments are stored in the odoo filestore of the server that created them. Solution: A solution to this problem is to hook the end of the upgrade process through the implementation of the *_register_hook* method on our *ir.attachment* model. Since This method is called after the completion of the upgrade process and the full initialization of the odoo registry, we can safely expect that all the information We need to know where to store the attachments that are available. From there, we can simply search for all the attachments that are not linked to any specific storage and store them in the appropriate storage.
1 parent 53941ea commit 777c459

File tree

5 files changed

+142
-78
lines changed

5 files changed

+142
-78
lines changed

fs_attachment/README.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Base Attachment Object Store
77
!! This file is generated by oca-gen-addon-readme !!
88
!! changes will be overwritten. !!
99
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
10-
!! source digest: sha256:bdd0b96b994e4dca01425e6a7bd363884a2789cca0bc9fe64d07cd047ee9ff71
10+
!! source digest: sha256:61a58639f0f0fe76909909b616893271d9e4ff2cdd569885eae7648b0d8dfcf5
1111
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
1212
1313
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png

fs_attachment/models/ir_attachment.py

Lines changed: 102 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from __future__ import annotations
66

7+
import inspect
78
import io
89
import logging
910
import mimetypes
@@ -13,7 +14,6 @@
1314
from contextlib import closing, contextmanager
1415

1516
import fsspec # pylint: disable=missing-manifest-dependency
16-
import psycopg2
1717
from slugify import slugify # pylint: disable=missing-manifest-dependency
1818

1919
import odoo
@@ -37,23 +37,6 @@ def is_true(strval):
3737
return bool(strtobool(strval or "0"))
3838

3939

40-
def clean_fs(files):
41-
_logger.info("cleaning old files from filestore")
42-
for full_path in files:
43-
if os.path.exists(full_path):
44-
try:
45-
os.unlink(full_path)
46-
except OSError:
47-
_logger.info(
48-
"_file_delete could not unlink %s", full_path, exc_info=True
49-
)
50-
except IOError:
51-
# Harmless and needed for race conditions
52-
_logger.info(
53-
"_file_delete could not unlink %s", full_path, exc_info=True
54-
)
55-
56-
5740
class IrAttachment(models.Model):
5841
_inherit = "ir.attachment"
5942

@@ -96,6 +79,46 @@ class IrAttachment(models.Model):
9679
ondelete="restrict",
9780
)
9881

82+
def init(self):
83+
res = super().init()
84+
# This partial index is used by the register hook to find attachments
85+
# to migrate from the odoo filestore to a fs storage
86+
query = """
87+
CREATE INDEX IF NOT EXISTS
88+
ir_attachment_no_fs_storage_code
89+
ON ir_attachment (fs_storage_code)
90+
WHERE fs_storage_code IS NULL;
91+
"""
92+
self.env.cr.execute(query)
93+
return res
94+
95+
def _register_hook(self):
96+
super()._register_hook()
97+
fs_storage_codes = self._get_storage_codes()
98+
# ignore if we are not using an object storages
99+
if not fs_storage_codes:
100+
return
101+
curframe = inspect.currentframe()
102+
calframe = inspect.getouterframes(curframe, 2)
103+
# the caller of _register_hook is 'load_modules' in
104+
# odoo/modules/loading.py
105+
load_modules_frame = calframe[1][0]
106+
# 'update_module' is an argument that 'load_modules' receives with a
107+
# True-ish value meaning that an install or upgrade of addon has been
108+
# done during the initialization. We need to move the attachments that
109+
# could have been created or updated in other addons before this addon
110+
# was loaded
111+
update_module = load_modules_frame.f_locals.get("update_module")
112+
113+
# We need to call the migration on the loading of the model because
114+
# when we are upgrading addons, some of them might add attachments.
115+
# To be sure they are migrated to the storage we need to call the
116+
# migration here.
117+
# Typical example is images of ir.ui.menu which are updated in
118+
# ir.attachment at every upgrade of the addons
119+
if update_module:
120+
self.sudo()._force_storage_to_object_storage()
121+
99122
@api.depends("name")
100123
def _compute_internal_url(self) -> None:
101124
for rec in self:
@@ -673,7 +696,7 @@ def _move_attachment_to_store(self):
673696
}
674697
)
675698
_logger.info("moved %s on the object storage", fname)
676-
return self._full_path(fname)
699+
self._mark_for_gc(fname)
677700
elif self.db_datas:
678701
_logger.info("moving on the object storage from database")
679702
self.write({"datas": self.datas})
@@ -760,67 +783,73 @@ def force_storage_to_db_for_special_fields(self, new_cr=False):
760783
)
761784

762785
@api.model
763-
def _force_storage_to_object_storage(self, new_cr=False):
786+
def _force_storage_to_object_storage(self):
764787
_logger.info("migrating files to the object storage")
765-
storage = self.env.context.get("storage_location") or self._storage()
766-
if self._is_storage_disabled(storage):
788+
is_disabled = is_true(os.environ.get("DISABLE_ATTACHMENT_STORAGE"))
789+
if is_disabled:
767790
return
768-
# The weird "res_field = False OR res_field != False" domain
769-
# is required! It's because of an override of _search in ir.attachment
770-
# which adds ('res_field', '=', False) when the domain does not
771-
# contain 'res_field'.
772-
# https://github.com/odoo/odoo/blob/9032617120138848c63b3cfa5d1913c5e5ad76db/
773-
# odoo/addons/base/ir/ir_attachment.py#L344-L347
774-
domain = [
775-
"!",
776-
("store_fname", "=like", "{}://%".format(storage)),
791+
all_storages = self.env["fs.storage"].search([])
792+
self._force_storage_for_specific_fields(all_storages)
793+
self._force_storage_for_specific_models(all_storages)
794+
self._force_storage_for_attachments(all_storages)
795+
796+
@property
797+
def _default_domain_for_force_storage(self):
798+
return [
799+
("fs_storage_code", "=", False),
800+
("store_fname", "!=", False),
777801
"|",
778802
("res_field", "=", False),
779803
("res_field", "!=", False),
780804
]
781-
# We do a copy of the environment so we can workaround the cache issue
782-
# below. We do not create a new cursor by default because it causes
783-
# serialization issues due to concurrent updates on attachments during
784-
# the installation
785-
with self._do_in_new_env(new_cr=new_cr) as new_env:
786-
model_env = new_env["ir.attachment"]
787-
ids = model_env.search(domain).ids
788-
files_to_clean = []
789-
for attachment_id in ids:
790-
try:
791-
with new_env.cr.savepoint():
792-
# check that no other transaction has
793-
# locked the row, don't send a file to storage
794-
# in that case
795-
self.env.cr.execute(
796-
"SELECT id "
797-
"FROM ir_attachment "
798-
"WHERE id = %s "
799-
"FOR UPDATE NOWAIT",
800-
(attachment_id,),
801-
log_exceptions=False,
802-
)
803-
804-
# This is a trick to avoid having the 'datas'
805-
# function fields computed for every attachment on
806-
# each iteration of the loop. The former issue
807-
# being that it reads the content of the file of
808-
# ALL the attachments on each loop.
809-
new_env.clear()
810-
attachment = model_env.browse(attachment_id)
811-
path = attachment._move_attachment_to_store()
812-
if path:
813-
files_to_clean.append(path)
814-
except psycopg2.OperationalError:
815-
_logger.error(
816-
"Could not migrate attachment %s to S3", attachment_id
817-
)
818805

819-
# delete the files from the filesystem once we know the changes
820-
# have been committed in ir.attachment
821-
if files_to_clean:
822-
new_env.cr.commit()
823-
clean_fs(files_to_clean)
806+
@api.model
807+
def _force_storage_for_specific_fields(self, fs_storages):
808+
"""Migrate attachments linked to model's fields for which a fs storage
809+
is configured and no fs_storage_code is set on the attachment
810+
"""
811+
domain = self._default_domain_for_force_storage
812+
fields = fs_storages.mapped("field_ids")
813+
fields_domain = []
814+
for field in fields:
815+
fields_domain.append(
816+
[("res_field", "=", field.name), ("res_model", "=", field.model_name)]
817+
)
818+
domain = AND([domain, OR(fields_domain)])
819+
for attachment in self.search(domain):
820+
attachment._move_attachment_to_store()
821+
822+
@api.model
823+
def _force_storage_for_specific_models(self, fs_storages):
824+
"""Migrate attachments linked to models for which a fs storage
825+
is configured and no fs_storage_code is set on the attachment.
826+
This method MUST be called after _force_storage_for_specific_fields
827+
to be sure that all the attachments linked to fields are migrated
828+
before migrating the attachments linked to models otherwise we
829+
will move some attachment with specific fs storage defined for
830+
its field to the default fs storage defined for its model.
831+
"""
832+
domain = self._default_domain_for_force_storage
833+
model_names = fs_storages.mapped("model_ids.model")
834+
domain = AND([domain, [("res_model", "in", model_names)]])
835+
for attachment in self.search(domain):
836+
attachment._move_attachment_to_store()
837+
838+
@api.model
839+
def _force_storage_for_attachments(self, fs_storages):
840+
"""Migrate attachments not stored into a filesystem storage if a
841+
filesystem storage is configured for attachments.
842+
843+
This method MUST be called after _force_storage_for_specific_fields
844+
and _force_storage_for_specific_models
845+
846+
"""
847+
if not self.env["fs.storage"].get_default_storage_code_for_attachments():
848+
# no default storage configured for attachments
849+
return
850+
domain = self._default_domain_for_force_storage
851+
for attachment in self.search(domain):
852+
attachment._move_attachment_to_store()
824853

825854

826855
class AttachmentFileLikeAdapter(object):
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
Fix attachments stored in Odoo after an addon upgrade.
2+
3+
Prerequisites:
4+
5+
* The fs_attachment addon is installed, and specific filesystem storage is
6+
configured.
7+
* All the attachments are already stored in the appropriate filesystem storage.
8+
(We assume that if you've installed the addon on an existing database,
9+
you'vealready migrated the existing attachments appropriately.
10+
11+
Context:
12+
13+
* You run an upgrade command on your Odoo server.
14+
15+
Problem:
16+
17+
Some attachments could be created during the upgrade process before the load of
18+
the fs_attachment module or the configuration of the filesystem storage backends.
19+
This is especially true for the attachments created by addons to store the app
20+
icons or even attachments from XML data files. As a result, you end up with
21+
attachments that could not be found by some Odoo instances if, for example, you have
22+
multiple Odoo instances running on different servers since these attachments are stored
23+
in the odoo filestore of the server that created them.
24+
25+
Solution:
26+
27+
A solution to this problem is to hook the end of the upgrade process through the
28+
implementation of the *_register_hook* method on our *ir.attachment* model. Since
29+
this method is called after the completion of the upgrade process and the full
30+
initialization of the odoo registry, we can safely expect that all the information
31+
we need to know where to store the attachments are available.
32+
From there, we can simply search for all the attachments that are not linked to
33+
any specific storage and store them in the appropriate storage.

fs_attachment/static/description/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ <h1 class="title">Base Attachment Object Store</h1>
367367
!! This file is generated by oca-gen-addon-readme !!
368368
!! changes will be overwritten. !!
369369
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
370-
!! source digest: sha256:bdd0b96b994e4dca01425e6a7bd363884a2789cca0bc9fe64d07cd047ee9ff71
370+
!! source digest: sha256:61a58639f0f0fe76909909b616893271d9e4ff2cdd569885eae7648b0d8dfcf5
371371
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
372372
<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/storage/tree/16.0/fs_attachment"><img alt="OCA/storage" src="https://img.shields.io/badge/github-OCA%2Fstorage-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/storage-16-0/storage-16-0-fs_attachment"><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/storage&amp;target_branch=16.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
373373
<p>In some cases, you need to store attachment in another system that the Odoo’s

fs_attachment/tests/test_fs_attachment.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -317,11 +317,13 @@ def test_force_storage_to_fs(self):
317317
self.assertEqual(os.listdir(self.temp_dir), [])
318318
# we decide to force the storage in the filestore
319319
self.temp_backend.use_as_default_for_attachments = True
320-
with mock.patch.object(self.env.cr, "commit"), mock.patch(
321-
"odoo.addons.fs_attachment.models.ir_attachment.clean_fs"
320+
with mock.patch.object(self.env.cr, "commit"), mock.patch.object(
321+
type(self.ir_attachment_model), "_mark_for_gc"
322322
) as clean_fs:
323+
fname = attachment.store_fname
323324
self.ir_attachment_model.force_storage()
324-
clean_fs.assert_called_once()
325+
clean_fs.assert_any_call(fname)
326+
325327
# files into the filestore must be moved to our filesystem storage
326328
filename = f"test-{attachment.id}-0.txt"
327329
self.assertEqual(attachment.store_fname, f"tmp_dir://{filename}")

0 commit comments

Comments
 (0)