From 9ae006d973604f780d79e3eb93ceb5113470ad13 Mon Sep 17 00:00:00 2001 From: Christopher Rogos Date: Tue, 21 Apr 2026 12:45:29 +0000 Subject: [PATCH 1/2] [IMP] fs_attachment: improve attachment storage handling of files without body (size=0) --- fs_attachment/models/ir_attachment.py | 12 ++++++++++++ fs_attachment/tests/test_fs_attachment.py | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/fs_attachment/models/ir_attachment.py b/fs_attachment/models/ir_attachment.py index 09a46e8cef..da4c8700c8 100644 --- a/fs_attachment/models/ir_attachment.py +++ b/fs_attachment/models/ir_attachment.py @@ -260,6 +260,18 @@ def _storage(self): storage = super()._storage() return storage + @api.depends("store_fname", "db_datas") + def _compute_raw(self): + """Always expose raw payload as bytes. + + Some callers (e.g. account EDI helpers) slice the value returned by + ``raw`` and crash when it is ``False`` for 0-byte attachments. + """ + res = super()._compute_raw() + false_attachments = self.filtered(lambda att: not att.raw) + false_attachments.raw = b"" + return res + @api.model_create_multi def create(self, vals_list): """ diff --git a/fs_attachment/tests/test_fs_attachment.py b/fs_attachment/tests/test_fs_attachment.py index a5116bd0fd..b1ffb0d4fe 100644 --- a/fs_attachment/tests/test_fs_attachment.py +++ b/fs_attachment/tests/test_fs_attachment.py @@ -77,6 +77,12 @@ def test_create_attachment_with_meaningful_name(self): with attachment.open("rb") as f: self.assertEqual(f.read(), new_content) + def test_create_attachment_with_no_payload_has_bytes_raw(self): + attachment = self.ir_attachment_model.create({"name": "empty.txt"}) + + self.assertEqual(attachment.raw, b"") + self.assertEqual(attachment.file_size, 0) + def test_open_attachment_in_db(self): self.env["ir.config_parameter"].sudo().set_param("ir_attachment.location", "db") content = b"This is a test attachment in db" From 6fa8ee214e4032516383fe9cac33d46d668799b9 Mon Sep 17 00:00:00 2001 From: Christopher Rogos Date: Tue, 7 Jul 2026 09:57:09 +0000 Subject: [PATCH 2/2] [IMP] fs_attachment: two attachments with same name and content --- fs_attachment/models/ir_attachment.py | 13 +++++- fs_attachment/tests/test_fs_attachment.py | 50 +++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/fs_attachment/models/ir_attachment.py b/fs_attachment/models/ir_attachment.py index da4c8700c8..3227f7c7c7 100644 --- a/fs_attachment/models/ir_attachment.py +++ b/fs_attachment/models/ir_attachment.py @@ -479,6 +479,7 @@ def _enforce_meaningful_storage_filename(self) -> None: Keeping the same meaning and mimetype is important to also ease to provide a meaningful and SEO friendly URL to the file in the filesystem storage. """ + renamed_attachments = {} for attachment in self: if not self._is_file_from_a_storage(attachment.store_fname): continue @@ -492,7 +493,17 @@ def _enforce_meaningful_storage_filename(self) -> None: new_filename_with_path = os.path.join( os.path.dirname(filename), new_filename ) - fs.rename(filename, new_filename_with_path) + + if filename in renamed_attachments: + if renamed_attachments[filename] == new_filename_with_path: + # we already renamed this file, no need to rename it again + continue + else: + fs.copy(renamed_attachments[filename], new_filename_with_path) + else: + fs.rename(filename, new_filename_with_path) + renamed_attachments[filename] = new_filename_with_path + attachment.fs_filename = new_filename # we need to update the store_fname with the new filename by # calling the write method of the field since the write method diff --git a/fs_attachment/tests/test_fs_attachment.py b/fs_attachment/tests/test_fs_attachment.py index b1ffb0d4fe..42dd4cd734 100644 --- a/fs_attachment/tests/test_fs_attachment.py +++ b/fs_attachment/tests/test_fs_attachment.py @@ -452,6 +452,56 @@ def test_create_attachments_basic_user(self): } ) + def test_create_two_attachments(self): + self.temp_backend.use_as_default_for_attachments = True + res = self.ir_attachment_model.create( + [ + {"name": "test2.txt", "raw": b"content"}, + {"name": "test.txt", "raw": b"content"}, + ] + ) + self.assertEqual(len(res), 2) + + def test_create_two_attachments_same_name(self): + self.temp_backend.use_as_default_for_attachments = True + res = self.ir_attachment_model.create( + [ + {"name": "test.txt", "raw": b"content"}, + {"name": "test.txt", "raw": b"content"}, + ] + ) + self.assertEqual(len(res), 2) + attachment1 = res[0] + attachment2 = res[1] + self.assertEqual(attachment1.name, "test.txt") + self.assertEqual(attachment2.name, "test.txt") + self.assertNotEqual(attachment1.store_fname, attachment2.store_fname) + self.assertEqual(attachment1.raw, attachment2.raw) + + attachment1.raw = b"new content" + self.assertNotEqual(attachment1.raw, attachment2.raw) + + attachment2 = self.ir_attachment_model.browse(attachment2.id) + self.assertEqual(attachment2.raw, b"content") + + def test_create_two_attachments_differnt_call(self): + self.temp_backend.use_as_default_for_attachments = True + res = self.ir_attachment_model.create( + [ + {"name": "test.txt", "raw": b"content"}, + ] + ) + res2 = self.ir_attachment_model.create( + [ + {"name": "test.txt", "raw": b"content"}, + ] + ) + self.assertEqual(len(res), 1) + self.assertEqual(len(res2), 1) + + self.assertNotEqual(res[0].store_fname, res2[0].store_fname) + self.assertEqual(res[0].raw, res2[0].raw) + def test_update_png_to_svg(self): b64_data_png = ( b"iVBORw0KGgoAAAANSUhEUgAAADMAAAAhCAIAAAD73QTtAAAAA3NCSVQICAjb4U/gAA"