|
4 | 4 |
|
5 | 5 | from __future__ import annotations |
6 | 6 |
|
| 7 | +import inspect |
7 | 8 | import io |
8 | 9 | import logging |
9 | 10 | import mimetypes |
|
13 | 14 | from contextlib import closing, contextmanager |
14 | 15 |
|
15 | 16 | import fsspec # pylint: disable=missing-manifest-dependency |
16 | | -import psycopg2 |
17 | 17 | from slugify import slugify # pylint: disable=missing-manifest-dependency |
18 | 18 |
|
19 | 19 | import odoo |
@@ -96,6 +96,46 @@ class IrAttachment(models.Model): |
96 | 96 | ondelete="restrict", |
97 | 97 | ) |
98 | 98 |
|
| 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 | + |
99 | 139 | @api.depends("name") |
100 | 140 | def _compute_internal_url(self) -> None: |
101 | 141 | for rec in self: |
@@ -673,7 +713,7 @@ def _move_attachment_to_store(self): |
673 | 713 | } |
674 | 714 | ) |
675 | 715 | _logger.info("moved %s on the object storage", fname) |
676 | | - return self._full_path(fname) |
| 716 | + self._mark_for_gc(fname) |
677 | 717 | elif self.db_datas: |
678 | 718 | _logger.info("moving on the object storage from database") |
679 | 719 | self.write({"datas": self.datas}) |
@@ -765,62 +805,67 @@ def _force_storage_to_object_storage(self, new_cr=False): |
765 | 805 | storage = self.env.context.get("storage_location") or self._storage() |
766 | 806 | if self._is_storage_disabled(storage): |
767 | 807 | 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), |
777 | 817 | "|", |
778 | 818 | ("res_field", "=", False), |
779 | 819 | ("res_field", "!=", False), |
780 | 820 | ] |
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 | | - ) |
818 | 821 |
|
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() |
824 | 869 |
|
825 | 870 |
|
826 | 871 | class AttachmentFileLikeAdapter(object): |
|
0 commit comments