feat(File): add helper to copy attachment to different doc (#37972)

This commit is contained in:
Raffael Meyer 2026-04-11 21:45:47 +02:00 committed by GitHub
parent 5fdebb67bf
commit 2ac1998000
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 93 additions and 0 deletions

View file

@ -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

View file

@ -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(