diff --git a/fs_attachment/models/__init__.py b/fs_attachment/models/__init__.py index bfe56d2fda..124d8ac44f 100644 --- a/fs_attachment/models/__init__.py +++ b/fs_attachment/models/__init__.py @@ -2,3 +2,5 @@ from . import fs_storage from . import ir_attachment from . import ir_binary +from . import ir_model +from . import ir_model_fields diff --git a/fs_attachment/models/fs_storage.py b/fs_attachment/models/fs_storage.py index 60203e865a..d1bf83258c 100644 --- a/fs_attachment/models/fs_storage.py +++ b/fs_attachment/models/fs_storage.py @@ -76,6 +76,36 @@ class FsStorage(models.Model): "files that are referenced by other systems (like a website) where " "the filename is important for SEO.", ) + model_xmlids = fields.Char( + help="List of models xml ids such as attachments linked to one of " + "these models will be stored in this storage." + ) + model_ids = fields.One2many( + "ir.model", + "storage_id", + help="List of models such as attachments linked to one of these " + "models will be stored in this storage.", + compute="_compute_model_ids", + inverse="_inverse_model_ids", + ) + field_xmlids = fields.Char( + help="List of fields xml ids such as attachments linked to one of " + "these fields will be stored in this storage. NB: If the attachment " + "is linked to a field that is in one FS storage, and the related " + "model is in another FS storage, we will store it into" + " the storage linked to the resource field." + ) + field_ids = fields.One2many( + "ir.model.fields", + "storage_id", + help="List of fields such as attachments linked to one of these " + "fields will be stored in this storage. NB: If the attachment " + "is linked to a field that is in one FS storage, and the related " + "model is in another FS storage, we will store it into" + " the storage linked to the resource field.", + compute="_compute_field_ids", + inverse="_inverse_field_ids", + ) @api.constrains("use_as_default_for_attachments") def _check_use_as_default_for_attachments(self): @@ -87,6 +117,64 @@ def _check_use_as_default_for_attachments(self): _("Only one storage can be used as default for attachments") ) + @api.constrains("model_xmlids") + def _check_model_xmlid_storage_unique(self): + """ + A given model can be stored in only 1 storage. + As model_ids is a non stored field, we must implement a Python + constraint on the XML ids list. + """ + for rec in self.filtered("model_xmlids"): + xmlids = rec.model_xmlids.split(",") + for xmlid in xmlids: + other_storages = ( + self.env["fs.storage"] + .search([]) + .filtered_domain( + [ + ("id", "!=", rec.id), + ("model_xmlids", "ilike", xmlid), + ] + ) + ) + if other_storages: + raise ValidationError( + _( + "Model %(model)s already stored in another " + "FS storage ('%(other_storage)s')" + ) + % {"model": xmlid, "other_storage": other_storages[0].name} + ) + + @api.constrains("field_xmlids") + def _check_field_xmlid_storage_unique(self): + """ + A given field can be stored in only 1 storage. + As field_ids is a non stored field, we must implement a Python + constraint on the XML ids list. + """ + for rec in self.filtered("field_xmlids"): + xmlids = rec.field_xmlids.split(",") + for xmlid in xmlids: + other_storages = ( + self.env["fs.storage"] + .search([]) + .filtered_domain( + [ + ("id", "!=", rec.id), + ("field_xmlids", "ilike", xmlid), + ] + ) + ) + if other_storages: + raise ValidationError( + _( + "Field %(field)s already stored in another " + "FS storage ('%(other_storage)s')" + ) + % {"field": xmlid, "other_storage": other_storages[0].name} + ) + @property def _server_env_fields(self): env_fields = super()._server_env_fields @@ -100,6 +188,8 @@ def _server_env_fields(self): "use_as_default_for_attachments": {}, "force_db_for_default_attachment_rules": {}, "use_filename_obfuscation": {}, + "model_xmlids": {}, + "field_xmlids": {}, } ) return env_fields @@ -117,6 +207,58 @@ def _onchange_use_as_default_for_attachments(self): self._default_force_db_for_default_attachment_rules ) + @api.depends("model_xmlids") + def _compute_model_ids(self): + """ + Use the char field (containing all model xmlids) to fulfill the o2m field. + """ + for rec in self: + xmlids = ( + rec.model_xmlids.split(",") if isinstance(rec.model_xmlids, str) else [] + ) + model_ids = [] + for xmlid in xmlids: + # Method returns False if no model is found for this xmlid + model_id = self.env["ir.model.data"]._xmlid_to_res_id(xmlid) + if model_id: + model_ids.append(model_id) + rec.model_ids = [(6, 0, model_ids)] + + def _inverse_model_ids(self): + """ + When the model_ids o2m field is updated, re-compute the char list + of model XML ids. + """ + for rec in self: + xmlids = models.Model.get_external_id(rec.model_ids).values() + rec.model_xmlids = ",".join(xmlids) + + @api.depends("field_xmlids") + def _compute_field_ids(self): + """ + Use the char field (containing all field xmlids) to fulfill the o2m field. + """ + for rec in self: + xmlids = ( + rec.field_xmlids.split(",") if isinstance(rec.field_xmlids, str) else [] + ) + field_ids = [] + for xmlid in xmlids: + # Method returns False if no field is found for this xmlid + field_id = self.env["ir.model.data"]._xmlid_to_res_id(xmlid) + if field_id: + field_ids.append(field_id) + rec.field_ids = [(6, 0, field_ids)] + + def _inverse_field_ids(self): + """ + When the field_ids o2m field is updated, re-compute the char list + of field XML ids. + """ + for rec in self: + xmlids = models.Model.get_external_id(rec.field_ids).values() + rec.field_xmlids = ",".join(xmlids) + @api.model_create_multi def create(self, vals_list): for vals in vals_list: @@ -156,9 +298,38 @@ def _check_force_db_for_default_attachment_rules(self): ) from e @api.model - @tools.ormcache() + @tools.ormcache_context(keys=["attachment_res_field", "attachment_res_model"]) def get_default_storage_code_for_attachments(self): - """Return the code of the storage to use to store by default the attachments""" + """Return the code of the storage to use to store the attachments. + If the resource field is linked to a particular storage, return this one. + Otherwise if the resource model is linked to a particular storage, + return it. + Finally return the code of the storage to use by default.""" + res_field = self.env.context.get("attachment_res_field") + res_model = self.env.context.get("attachment_res_model") + if res_field and res_model: + field = self.env["ir.model.fields"].search( + [("model", "=", res_model), ("name", "=", res_field)], limit=1 + ) + if field: + storage = ( + self.env["fs.storage"] + .search([]) + .filtered_domain([("field_ids", "in", [field.id])]) + ) + if storage: + return storage.code + if res_model: + model = self.env["ir.model"].search([("model", "=", res_model)], limit=1) + if model: + storage = ( + self.env["fs.storage"] + .search([]) + .filtered_domain([("model_ids", "in", [model.id])]) + ) + if storage: + return storage.code + storages = self.search([]).filtered_domain( [("use_as_default_for_attachments", "=", True)] ) diff --git a/fs_attachment/models/ir_attachment.py b/fs_attachment/models/ir_attachment.py index 9ee2d85619..566b89894c 100644 --- a/fs_attachment/models/ir_attachment.py +++ b/fs_attachment/models/ir_attachment.py @@ -246,7 +246,29 @@ def _storage(self): @api.model_create_multi def create(self, vals_list): - attachments = super().create(vals_list) + """ + Storage may depend on resource field, but the method calling _storage + (_get_datas_related_values) does not take all vals, just the mimetype. + The only way to give res_field and res_model to _storage method + is to pass them into the context, and perform 1 create call per record + to create. + """ + vals_list_no_model = [] + attachments = self.env["ir.attachment"] + for vals in vals_list: + if vals.get("res_model"): + attachment = super( + IrAttachment, + self.with_context( + attachment_res_model=vals.get("res_model"), + attachment_res_field=vals.get("res_field"), + ), + ).create(vals) + attachments += attachment + else: + vals_list_no_model.append(vals) + atts = super().create(vals_list_no_model) + attachments |= atts attachments._enforce_meaningful_storage_filename() return attachments @@ -275,7 +297,19 @@ def write(self, vals): "mimetypes at the same time." ) ) - return super().write(vals) + for rec in self: + # As when creating a new attachment, we must pass the res_field + # and res_model into the context hence sadly we must perform 1 call + # for each attachment + super( + IrAttachment, + rec.with_context( + attachment_res_model=vals.get("res_model") or rec.res_model, + attachment_res_field=vals.get("res_field") or rec.res_field, + ), + ).write(vals) + + return True @api.model def _file_read(self, fname): @@ -975,7 +1009,10 @@ def _file_open(self) -> io.IOBase: # content and checksum to avoid collision. content = self._gen_random_content() checksum = self.attachment._compute_checksum(content) - new_store_fname = self.attachment._file_write(content, checksum) + new_store_fname = self.attachment.with_context( + attachment_res_model=self.attachment.res_model, + attachment_res_field=self.attachment.res_field, + )._file_write(content, checksum) if self.attachment._is_file_from_a_storage(new_store_fname): ( filesystem, diff --git a/fs_attachment/models/ir_model.py b/fs_attachment/models/ir_model.py new file mode 100644 index 0000000000..cb0be1b12c --- /dev/null +++ b/fs_attachment/models/ir_model.py @@ -0,0 +1,15 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class IrModel(models.Model): + + _inherit = "ir.model" + + storage_id = fields.Many2one( + "fs.storage", + help="If specified, all attachments linked to this model will be " + "stored in the provided storage.", + ) diff --git a/fs_attachment/models/ir_model_fields.py b/fs_attachment/models/ir_model_fields.py new file mode 100644 index 0000000000..7660e0b67d --- /dev/null +++ b/fs_attachment/models/ir_model_fields.py @@ -0,0 +1,15 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class IrModelFields(models.Model): + + _inherit = "ir.model.fields" + + storage_id = fields.Many2one( + "fs.storage", + help="If specified, all attachments linked to this field will be " + "stored in the provided storage.", + ) diff --git a/fs_attachment/readme/CONTRIBUTORS.rst b/fs_attachment/readme/CONTRIBUTORS.rst index b28dec9346..b06e37ea72 100644 --- a/fs_attachment/readme/CONTRIBUTORS.rst +++ b/fs_attachment/readme/CONTRIBUTORS.rst @@ -9,3 +9,4 @@ Patrick Tombez Don Kendall Stephane Mangin Laurent Mignon +Marie Lejeune diff --git a/fs_attachment/readme/USAGE.rst b/fs_attachment/readme/USAGE.rst index 9cd3b1b002..927bfda025 100644 --- a/fs_attachment/readme/USAGE.rst +++ b/fs_attachment/readme/USAGE.rst @@ -70,6 +70,21 @@ attachments will be stored in the filesystem. This option is only available on the filesystem storage that is used as default for attachments. +It is also possible to use different FS storages for attachments linked to +different resource fields/models. You can configure it either on the ``fs.storage`` +directly, or in a server environment file: + +* From the ``fs.storage``: Fields `model_ids` and `field_ids` will encode for which + models/fields use this storage as default storage for attachments having these resource + model/field. Note that if an attachment has both resource model and field, it will + first take the FS storage where the field is explicitely linked, then is not found, + the one where the model is explicitely linked. + +* From a server environment file: In this case you just have to provide a comma- + separated list of models (under the `model_xmlids` key) or fields (under the + `field_xmlids` key). To do so, use the model/field XML ids provided by Odoo. + See the Server Environment section for a concrete example. + Another key feature of this module is the ability to get access to the attachments from URLs. @@ -133,6 +148,8 @@ provide values for the following keys: * ``use_as_default_for_attachments`` * ``force_db_for_default_attachment_rules`` * ``use_filename_obfuscation`` +* ``model_xmlids`` +* ``field_xmlids`` For example, the configuration of my storage with code `fsprod` used to store the attachments by default could be: @@ -145,6 +162,8 @@ the attachments by default could be: directory_path=my_bucket use_as_default_for_attachments=True use_filename_obfuscation=True + model_xmlids=base.model_res_lang,base.model_res_country + field_xmlids=base.field_res_partner__image_128 Advanced usage: Using attachment as a file ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -177,8 +196,8 @@ It's always safer to prefer the second approach. When your attachment is stored into the odoo filestore or into an external filesystem storage, each time you call the open method, a new file is created. -This way of doing ensures that if the transaction is rollback the original content -is preserve. Nevertheless you could have use cases where you would like to write +This way of doing ensures that if the transaction is rolled back the original content +is preserved. Nevertheless you could have use cases where you would like to write to the existing file directly. For example you could create an empty attachment to store a csv report and then use the `open` method to write your content directly into the new file. To support this kind a use cases, the parameter `new_version` diff --git a/fs_attachment/tests/__init__.py b/fs_attachment/tests/__init__.py index 7f56d04124..b3a4b19e2e 100644 --- a/fs_attachment/tests/__init__.py +++ b/fs_attachment/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_fs_attachment from . import test_fs_attachment_file_like_adapter from . import test_fs_attachment_internal_url +from . import test_fs_storage diff --git a/fs_attachment/tests/test_fs_attachment_file_like_adapter.py b/fs_attachment/tests/test_fs_attachment_file_like_adapter.py index 44ee875df4..bac729c136 100644 --- a/fs_attachment/tests/test_fs_attachment_file_like_adapter.py +++ b/fs_attachment/tests/test_fs_attachment_file_like_adapter.py @@ -27,7 +27,7 @@ def open(self, attachment=None, mode="rb", new_version=False, **kwargs): ) def test_read(self): - with self.open(model="rf") as f: + with self.open(mode="rb") as f: self.assertEqual(f.read(), self.initial_content) def test_write(self): @@ -148,3 +148,86 @@ def _create_attachment(cls): return cls.env["ir.attachment"].create( {"name": "test.txt", "raw": cls.initial_content} ) + + +class TestAttachmentInFileSystemDependingModelFileLikeAdapter( + TestFSAttachmentCommon, TestFSAttachmentFileLikeAdapterMixin +): + """ + Configure the temp backend to store only attachments linked to + res.partner model. + + Check that opening/updating the file does not change the storage type. + """ + + @classmethod + def setUpClass(cls): + res = super().setUpClass() + cls.temp_backend.model_xmlids = "base.model_res_partner" + cls.prepareClass() + return res + + def setUp(self): + super().setUp() + super().prepare() + + @classmethod + def _create_attachment(cls): + return ( + cls.env["ir.attachment"] + .with_context( + storage_file_path="test.txt", + ) + .create( + { + "name": "test.txt", + "raw": cls.initial_content, + "res_model": "res.partner", + } + ) + ) + + def test_storage_location(self): + self.assertEqual(self.attachment.fs_storage_id, self.temp_backend) + + +class TestAttachmentInFileSystemDependingFieldFileLikeAdapter( + TestFSAttachmentCommon, TestFSAttachmentFileLikeAdapterMixin +): + """ + Configure the temp backend to store only attachments linked to + res.country ID field. + + Check that opening/updating the file does not change the storage type. + """ + + @classmethod + def setUpClass(cls): + res = super().setUpClass() + cls.temp_backend.field_xmlids = "base.field_res_country__id" + cls.prepareClass() + return res + + def setUp(self): + super().setUp() + super().prepare() + + @classmethod + def _create_attachment(cls): + return ( + cls.env["ir.attachment"] + .with_context( + storage_file_path="test.txt", + ) + .create( + { + "name": "test.txt", + "raw": cls.initial_content, + "res_model": "res.country", + "res_field": "id", + } + ) + ) + + def test_storage_location(self): + self.assertEqual(self.attachment.fs_storage_id, self.temp_backend) diff --git a/fs_attachment/tests/test_fs_storage.py b/fs_attachment/tests/test_fs_storage.py new file mode 100644 index 0000000000..b0ca790eba --- /dev/null +++ b/fs_attachment/tests/test_fs_storage.py @@ -0,0 +1,325 @@ +# Copyright 2023 ACSONE SA/NV (http://acsone.eu). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import os + +from odoo.exceptions import ValidationError + +from .common import TestFSAttachmentCommon + + +class TestFsStorage(TestFSAttachmentCommon): + @classmethod + def setUpClass(cls): + res = super().setUpClass() + cls.default_backend = cls.env.ref("fs_storage.default_fs_storage") + return res + + def test_compute_model_ids(self): + """ + Give a list of model xmlids and check that the o2m field model_ids + is correctly fulfilled. + """ + self.temp_backend.model_xmlids = ( + "base.model_res_partner,base.model_ir_attachment" + ) + + model_ids = self.temp_backend.model_ids + self.assertEqual(len(model_ids), 2) + model_names = model_ids.mapped("model") + self.assertEqual(set(model_names), {"res.partner", "ir.attachment"}) + + def test_inverse_model_ids(self): + """ + Modify backend model_ids and check the char field model_xmlids + is correctly updated + """ + model_1 = self.env["ir.model"].search([("model", "=", "res.partner")]) + model_2 = self.env["ir.model"].search([("model", "=", "ir.attachment")]) + self.temp_backend.model_ids = [(6, 0, [model_1.id, model_2.id])] + self.assertEqual( + self.temp_backend.model_xmlids, + "base.model_res_partner,base.model_ir_attachment", + ) + + def test_compute_field_ids(self): + """ + Give a list of field xmlids and check that the o2m field field_ids + is correctly fulfilled. + """ + self.temp_backend.field_xmlids = ( + "base.field_res_partner__id,base.field_res_partner__create_date" + ) + + field_ids = self.temp_backend.field_ids + self.assertEqual(len(field_ids), 2) + field_names = field_ids.mapped("name") + self.assertEqual(set(field_names), {"id", "create_date"}) + field_models = field_ids.mapped("model") + self.assertEqual(set(field_models), {"res.partner"}) + + def test_inverse_field_ids(self): + """ + Modify backend field_ids and check the char field field_xmlids + is correctly updated + """ + field_1 = self.env["ir.model.fields"].search( + [("model", "=", "res.partner"), ("name", "=", "id")] + ) + field_2 = self.env["ir.model.fields"].search( + [("model", "=", "res.partner"), ("name", "=", "create_date")] + ) + self.temp_backend.field_ids = [(6, 0, [field_1.id, field_2.id])] + self.assertEqual( + self.temp_backend.field_xmlids, + "base.field_res_partner__id,base.field_res_partner__create_date", + ) + + def test_constraint_unique_storage_model(self): + """ + A given model can be linked to a unique storage + """ + self.temp_backend.model_xmlids = ( + "base.model_res_partner,base.model_ir_attachment" + ) + self.env.ref("fs_storage.default_fs_storage") + with self.assertRaises(ValidationError): + self.default_backend.model_xmlids = "base.model_res_partner" + + def test_constraint_unique_storage_field(self): + """ + A given field can be linked to a unique storage + """ + self.temp_backend.field_xmlids = ( + "base.field_res_partner__id,base.field_res_partner__name" + ) + with self.assertRaises(ValidationError): + self.default_backend.field_xmlids = "base.field_res_partner__name" + + def test_force_model_create_attachment(self): + """ + Force 'res.partner' model to temp_backend + Use odoofs as default for attachments + * Check that only attachments linked to res.partner model are stored + in the first FS. + * Check that updating this first attachment does not change the storage + """ + self.default_backend.use_as_default_for_attachments = True + self.temp_backend.model_xmlids = "base.model_res_partner" + + # 1a. First attachment linked to res.partner model + content = b"This is a test attachment linked to res.partner model" + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content, "res_model": "res.partner"} + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + initial_filename = f"test-{attachment.id}-0.txt" + + self.assertEqual(attachment.fs_storage_code, self.temp_backend.code) + self.assertEqual(os.listdir(self.temp_dir), [initial_filename]) + with attachment.open("rb") as f: + self.assertEqual(f.read(), content) + with open(os.path.join(self.temp_dir, initial_filename), "rb") as f: + self.assertEqual(f.read(), content) + + # 1b. Update the attachment + new_content = b"Update the test attachment" + attachment.raw = new_content + with attachment.open("rb") as f: + self.assertEqual(f.read(), new_content) + # a new file version is created + new_filename = f"test-{attachment.id}-1.txt" + with open(os.path.join(self.temp_dir, new_filename), "rb") as f: + self.assertEqual(f.read(), new_content) + self.assertEqual(attachment.raw, new_content) + self.assertEqual(attachment.store_fname, f"tmp_dir://{new_filename}") + + # 2. Second attachment linked to res.country model + content = b"This is a test attachment linked to res.country model" + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content, "res_model": "res.country"} + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + self.assertEqual(attachment.fs_storage_code, self.default_backend.code) + + def test_force_field_create_attachment(self): + """ + Force 'base.field_res.partner__name' field to temp_backend + Use odoofs as default for attachments + * Check that only attachments linked to res.partner name field are stored + in the first FS. + * Check that updating this first attachment does not change the storage + """ + self.default_backend.use_as_default_for_attachments = True + self.temp_backend.field_xmlids = "base.field_res_partner__name" + + # 1a. First attachment linked to res.partner name field + content = b"This is a test attachment linked to res.partner name field" + attachment = self.ir_attachment_model.create( + { + "name": "test.txt", + "raw": content, + "res_model": "res.partner", + "res_field": "name", + } + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + initial_filename = f"test-{attachment.id}-0.txt" + + self.assertEqual(attachment.fs_storage_code, self.temp_backend.code) + self.assertEqual(os.listdir(self.temp_dir), [initial_filename]) + with attachment.open("rb") as f: + self.assertEqual(f.read(), content) + with open(os.path.join(self.temp_dir, initial_filename), "rb") as f: + self.assertEqual(f.read(), content) + + # 1b. Update the attachment + new_content = b"Update the test attachment" + attachment.raw = new_content + with attachment.open("rb") as f: + self.assertEqual(f.read(), new_content) + # a new file version is created + new_filename = f"test-{attachment.id}-1.txt" + with open(os.path.join(self.temp_dir, new_filename), "rb") as f: + self.assertEqual(f.read(), new_content) + self.assertEqual(attachment.raw, new_content) + self.assertEqual(attachment.store_fname, f"tmp_dir://{new_filename}") + + # 2. Second attachment linked to res.partner but other field (website) + content = b"This is a test attachment linked to res.partner website field" + attachment = self.ir_attachment_model.create( + { + "name": "test.txt", + "raw": content, + "res_model": "res.partner", + "res_field": "website", + } + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + self.assertEqual(attachment.fs_storage_code, self.default_backend.code) + + # 3. Third attachment linked to res.partner but no specific field + content = b"This is a test attachment linked to res.partner model" + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content, "res_model": "res.partner"} + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + self.assertEqual(attachment.fs_storage_code, self.default_backend.code) + + def test_force_field_and_model_create_attachment(self): + """ + Force res.partner model to default_backend. + But force specific res.partner name field to temp_backend. + * Check that attachments linked to res.partner name field are + stored in temp_backend, and other attachments linked to other + fields of res.partner are stored in default_backend + * Check that updating this first attachment does not change the storage + """ + self.default_backend.model_xmlids = "base.model_res_partner" + self.temp_backend.field_xmlids = "base.field_res_partner__name" + + # 1a. First attachment linked to res.partner name field + content = b"This is a test attachment linked to res.partner name field" + attachment = self.ir_attachment_model.create( + { + "name": "test.txt", + "raw": content, + "res_model": "res.partner", + "res_field": "name", + } + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + initial_filename = f"test-{attachment.id}-0.txt" + + self.assertEqual(attachment.fs_storage_code, self.temp_backend.code) + self.assertEqual(os.listdir(self.temp_dir), [initial_filename]) + with attachment.open("rb") as f: + self.assertEqual(f.read(), content) + with open(os.path.join(self.temp_dir, initial_filename), "rb") as f: + self.assertEqual(f.read(), content) + + # 1b. Update the attachment + new_content = b"Update the test attachment" + attachment.raw = new_content + with attachment.open("rb") as f: + self.assertEqual(f.read(), new_content) + # a new file version is created + new_filename = f"test-{attachment.id}-1.txt" + with open(os.path.join(self.temp_dir, new_filename), "rb") as f: + self.assertEqual(f.read(), new_content) + self.assertEqual(attachment.raw, new_content) + self.assertEqual(attachment.store_fname, f"tmp_dir://{new_filename}") + + # 2. Second attachment linked to res.partner but other field (website) + content = b"This is a test attachment linked to res.partner website field" + attachment = self.ir_attachment_model.create( + { + "name": "test.txt", + "raw": content, + "res_model": "res.partner", + "res_field": "website", + } + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + self.assertEqual(attachment.fs_storage_code, self.default_backend.code) + + # 3. Third attachment linked to res.partner but no specific field + content = b"This is a test attachment linked to res.partner model" + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content, "res_model": "res.partner"} + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + self.assertEqual(attachment.fs_storage_code, self.default_backend.code) + + # Fourth attachment linked to res.country: no storage because + # no default FS storage + content = b"This is a test attachment linked to res.country model" + attachment = self.ir_attachment_model.create( + {"name": "test.txt", "raw": content, "res_model": "res.country"} + ) + self.assertTrue(attachment.store_fname) + self.assertFalse(attachment.db_datas) + self.assertEqual(attachment.raw, content) + self.assertEqual(attachment.mimetype, "text/plain") + self.env.flush_all() + + self.assertFalse(attachment.fs_storage_code) diff --git a/fs_attachment/views/fs_storage.xml b/fs_attachment/views/fs_storage.xml index 8754440670..114cbdd776 100644 --- a/fs_attachment/views/fs_storage.xml +++ b/fs_attachment/views/fs_storage.xml @@ -9,6 +9,18 @@ + + + + + + + + + + + +