From 2ac1998000af597834673b02a5d1096b715de078 Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Sat, 11 Apr 2026 21:45:47 +0200 Subject: [PATCH] feat(File): add helper to copy attachment to different doc (#37972) --- frappe/core/doctype/file/file.py | 33 +++++++++++++++ frappe/core/doctype/file/test_file.py | 60 +++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 996180c768..b1fa044e95 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -115,6 +115,16 @@ class File(Document): if self.is_folder: return + if self.flags.copy_from_existing_file: + # Preserve the normal insert lifecycle for hooks and validations, but skip + # reprocessing an existing blob that is already referenced by `file_url`. + if not self.file_url: + frappe.throw( + _("File URL is required when copying an existing attachment."), + exc=frappe.MandatoryError, + ) + return + if self.is_remote_file: self.validate_remote_file() else: @@ -128,6 +138,29 @@ class File(Document): if not self.is_folder: self.create_attachment_record() + def create_attachment_copy( + self, + attached_to_doctype: str, + attached_to_name: str, + attached_to_field: str | None = None, + ignore_permissions: bool = False, + ): + """Efficiently copy an attachment from one document to another by reusing `file_url`.""" + if self.is_folder: + frappe.throw(_("Cannot attach a folder to a document")) + + attachment = frappe.copy_doc(self) + attachment.update( + { + "attached_to_doctype": attached_to_doctype, + "attached_to_name": attached_to_name, + "attached_to_field": attached_to_field, + } + ) + attachment.folder = None + attachment.flags.copy_from_existing_file = True + return attachment.insert(ignore_permissions=ignore_permissions) + def validate(self): if self.is_folder: return diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index fb14b30075..db0c0a4b42 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -254,6 +254,66 @@ class TestSameContent(IntegrationTestCase): limit_property.delete() frappe.clear_cache(doctype="ToDo") + def test_create_attachment_copy(self): + doctype, docname = make_test_doc() + source_file = frappe.get_doc( + { + "doctype": "File", + "file_name": f"existing-file-{frappe.generate_hash(length=8)}.txt", + "content": "Existing attachment content", + } + ).insert() + comment_count_before = frappe.db.count( + "Comment", {"reference_doctype": doctype, "reference_name": docname} + ) + + copied_file = source_file.create_attachment_copy(doctype, docname) + comment_count_after = frappe.db.count( + "Comment", {"reference_doctype": doctype, "reference_name": docname} + ) + + self.assertNotEqual(copied_file.name, source_file.name) + self.assertEqual(copied_file.file_url, source_file.file_url) + self.assertEqual(copied_file.attached_to_doctype, doctype) + self.assertEqual(copied_file.attached_to_name, docname) + self.assertEqual( + copied_file.folder, + frappe.db.get_value("File", {"is_attachments_folder": 1}), + ) + self.assertEqual(comment_count_after, comment_count_before + 1) + + def test_create_attachment_copy_respects_attachment_limit(self): + doctype, docname = make_test_doc() + from frappe.custom.doctype.property_setter.property_setter import make_property_setter + + limit_property = make_property_setter("ToDo", None, "max_attachments", 1, "int", for_doctype=True) + source_file_1 = frappe.get_doc( + { + "doctype": "File", + "file_name": f"existing-limit-file-{frappe.generate_hash(length=8)}.txt", + "content": "Existing attachment content 1", + } + ).insert() + source_file_2 = frappe.get_doc( + { + "doctype": "File", + "file_name": f"existing-limit-file-{frappe.generate_hash(length=8)}.txt", + "content": "Existing attachment content 2", + } + ).insert() + + try: + source_file_1.create_attachment_copy(doctype, docname) + self.assertRaises( + frappe.exceptions.AttachmentLimitReached, + source_file_2.create_attachment_copy, + doctype, + docname, + ) + finally: + limit_property.delete() + frappe.clear_cache(doctype="ToDo") + def test_utf8_bom_content_decoding(self): utf8_bom_content = test_content1.encode("utf-8-sig") _file: frappe.Document = frappe.get_doc(