44
55from __future__ import annotations
66
7+ import inspect
78import io
89import logging
910import mimetypes
1314from contextlib import closing , contextmanager
1415
1516import fsspec # pylint: disable=missing-manifest-dependency
16- import psycopg2
1717from slugify import slugify # pylint: disable=missing-manifest-dependency
1818
1919import 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-
5740class 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
826855class AttachmentFileLikeAdapter (object ):
0 commit comments