Skip to content

Commit 3ff4888

Browse files
committed
[FIX] fs_attachment: Fix attachment store after upgrade
1 parent 53941ea commit 3ff4888

File tree

1 file changed

+98
-53
lines changed

1 file changed

+98
-53
lines changed

fs_attachment/models/ir_attachment.py

Lines changed: 98 additions & 53 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
@@ -96,6 +96,46 @@ class IrAttachment(models.Model):
9696
ondelete="restrict",
9797
)
9898

99+
def init(self):
100+
res = super().init()
101+
# This partial index is used by the register hook to find attachments
102+
# to migrate from the odoo filestore to a fs storage
103+
query = """
104+
CREATE INDEX IF NOT EXISTS
105+
ir_attachment_no_fs_storage_code
106+
ON ir_attachment (fs_storage_code)
107+
WHERE fs_storage_code IS NULL;
108+
"""
109+
self.env.cr.execute(query)
110+
return res
111+
112+
def _register_hook(self):
113+
super()._register_hook()
114+
fs_storage_codes = self._get_storage_codes()
115+
# ignore if we are not using an object storages
116+
if not fs_storage_codes:
117+
return
118+
curframe = inspect.currentframe()
119+
calframe = inspect.getouterframes(curframe, 2)
120+
# the caller of _register_hook is 'load_modules' in
121+
# odoo/modules/loading.py
122+
load_modules_frame = calframe[1][0]
123+
# 'update_module' is an argument that 'load_modules' receives with a
124+
# True-ish value meaning that an install or upgrade of addon has been
125+
# done during the initialization. We need to move the attachments that
126+
# could have been created or updated in other addons before this addon
127+
# was loaded
128+
update_module = load_modules_frame.f_locals.get("update_module")
129+
130+
# We need to call the migration on the loading of the model because
131+
# when we are upgrading addons, some of them might add attachments.
132+
# To be sure they are migrated to the storage we need to call the
133+
# migration here.
134+
# Typical example is images of ir.ui.menu which are updated in
135+
# ir.attachment at every upgrade of the addons
136+
if update_module:
137+
self.sudo()._force_storage_to_object_storage()
138+
99139
@api.depends("name")
100140
def _compute_internal_url(self) -> None:
101141
for rec in self:
@@ -673,7 +713,7 @@ def _move_attachment_to_store(self):
673713
}
674714
)
675715
_logger.info("moved %s on the object storage", fname)
676-
return self._full_path(fname)
716+
self._mark_for_gc(fname)
677717
elif self.db_datas:
678718
_logger.info("moving on the object storage from database")
679719
self.write({"datas": self.datas})
@@ -765,62 +805,67 @@ def _force_storage_to_object_storage(self, new_cr=False):
765805
storage = self.env.context.get("storage_location") or self._storage()
766806
if self._is_storage_disabled(storage):
767807
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)),
808+
all_storages = self.env["fs.storage"].search([])
809+
self._force_storage_for_specific_fields(all_storages, new_cr=new_cr)
810+
self._force_storage_for_specific_models(all_storages, new_cr=new_cr)
811+
self._force_storage_for_attachments(all_storages, new_cr=new_cr)
812+
813+
@property
814+
def _default_domain_for_force_storage(self):
815+
return [
816+
("fs_storage_code", "=", False),
777817
"|",
778818
("res_field", "=", False),
779819
("res_field", "!=", False),
780820
]
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-
)
818821

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)
822+
@api.model
823+
def _force_storage_for_specific_fields(self, fs_storages, new_cr=False):
824+
"""Migrate attachments linked to model's fields for which a fs storage
825+
is configured and no fs_storage_code is set on the attachment
826+
"""
827+
domain = self._default_domain_for_force_storage
828+
fields = fs_storages.mapped("field_ids")
829+
fields_domain = []
830+
for field in fields:
831+
fields_domain.append(
832+
[("res_field", "=", field.name), ("res_model", "=", field.model_name)]
833+
)
834+
domain = AND([domain, OR(fields_domain)])
835+
for attachment in self.search(domain):
836+
attachment._move_attachment_to_store()
837+
838+
@api.model
839+
def _force_storage_for_specific_models(self, fs_storages, new_cr=False):
840+
"""Migrate attachments linked to models for which a fs storage
841+
is configured and no fs_storage_code is set on the attachment.
842+
This method MUST be called after _force_storage_for_specific_fields
843+
to be sure that all the attachments linked to fields are migrated
844+
before migrating the attachments linked to models otherwise we
845+
will move some attachment with specific fs storage defined for
846+
its field to the default fs storage defined for its model.
847+
"""
848+
domain = self._default_domain_for_force_storage
849+
model_names = fs_storages.mapped("model_ids.model")
850+
domain = AND([domain, [("res_model", "in", model_names)]])
851+
for attachment in self.search(domain):
852+
attachment._move_attachment_to_store()
853+
854+
@api.model
855+
def _force_storage_for_attachments(self, fs_storages, new_cr=False):
856+
"""Migrate attachments not stored into a filesystem storage if a
857+
filesystem storage is configured for attachments.
858+
859+
This method MUST be called after _force_storage_for_specific_fields
860+
and _force_storage_for_specific_models
861+
862+
"""
863+
if not self.env["fs.storage"].get_default_storage_code_for_attachments():
864+
# no default storage configured for attachments
865+
return
866+
domain = self._default_domain_for_force_storage
867+
for attachment in self.search(domain):
868+
attachment._move_attachment_to_store()
824869

825870

826871
class AttachmentFileLikeAdapter(object):

0 commit comments

Comments
 (0)