From 3f5da98b276ceb537499582236731673c19d83b6 Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Mon, 31 Mar 2025 16:00:26 +0200 Subject: [PATCH] feat: protect attached files (#31855) * feat: protect attached files * fix: protection does not apply to draft documents * chore: update descriptions * feat: hide delete button when file is protected --- frappe/core/doctype/doctype/doctype.json | 24 ++++++++++---- frappe/core/doctype/doctype/doctype.py | 1 + frappe/core/doctype/file/file.py | 32 +++++++++++++++++++ .../customize_form/customize_form.json | 12 +++++-- .../doctype/customize_form/customize_form.py | 2 ++ .../js/frappe/form/sidebar/attachments.js | 17 +++++++++- 6 files changed, 78 insertions(+), 10 deletions(-) diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index 6b9d744e80..4c98bb6788 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -15,6 +15,7 @@ "restrict_to_domain", "read_only", "in_create", + "protect_attached_files", "sb1", "naming_rule", "autoname", @@ -683,14 +684,22 @@ "options": "Dynamic\nCompressed" }, { - "default": "50", - "depends_on": "istable", - "fieldname": "grid_page_length", - "fieldtype": "Int", - "label": "Grid Page Length", - "non_negative": 1 + "default": "50", + "depends_on": "istable", + "fieldname": "grid_page_length", + "fieldtype": "Int", + "label": "Grid Page Length", + "non_negative": 1 + }, + { + "default": "0", + "description": "Users are only able to delete attached files if the document is either in draft or if the document is canceled and they are also able to delete the document.", + "fieldname": "protect_attached_files", + "fieldtype": "Check", + "label": "Protect Attached Files" } ], + "grid_page_length": 50, "icon": "fa fa-bolt", "idx": 6, "index_web_pages_for_search": 1, @@ -771,7 +780,7 @@ "link_fieldname": "reference_doctype" } ], - "modified": "2025-02-20 19:05:52.119679", + "modified": "2025-03-27 18:16:53.286909", "modified_by": "Administrator", "module": "Core", "name": "DocType", @@ -801,6 +810,7 @@ } ], "route": "doctype", + "row_format": "Dynamic", "search_fields": "module", "show_name_in_global_search": 1, "sort_field": "creation", diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index c73e65f905..0860e80297 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -154,6 +154,7 @@ class DocType(Document): ] nsm_parent_field: DF.Data | None permissions: DF.Table[DocPerm] + protect_attached_files: DF.Check queue_in_background: DF.Check quick_entry: DF.Check read_only: DF.Check diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 5011632663..412976464b 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -14,6 +14,7 @@ from PIL import Image, ImageFile, ImageOps import frappe from frappe import _ from frappe.database.schema import SPECIAL_CHAR_PATTERN +from frappe.exceptions import DoesNotExistError from frappe.model.document import Document from frappe.permissions import SYSTEM_USER_ROLE, get_doctypes_with_read from frappe.utils import call_hook_method, cint, get_files_path, get_hook_method, get_url @@ -151,6 +152,7 @@ class File(Document): if self.is_home_folder or self.is_attachments_folder: frappe.throw(_("Cannot delete Home and Attachments folders")) self.validate_empty_folder() + self.validate_protected_file() self._delete_file_on_disk() if not self.is_folder: self.add_comment_in_reference_doc("Attachment Removed", _("Removed {0}").format(self.file_name)) @@ -469,6 +471,36 @@ class File(Document): if self.is_folder and frappe.get_all("File", filters={"folder": self.name}, limit=1): frappe.throw(_("Folder {0} is not empty").format(self.name), FolderNotEmpty) + def validate_protected_file(self): + """Throw an exception if this file is attached to a doctype that protects files. + + Allows deleting the attached file if the linked document is in draft. If submitted, + deletion is not allowed. If canceled, requires delete permissions on the linked document. + """ + if not (self.attached_to_doctype and self.attached_to_name): + return + + try: + ref_doc = frappe.get_doc(self.attached_to_doctype, self.attached_to_name) + except DoesNotExistError: + return + + if ref_doc.docstatus == 0: + # If the document is not submitted yet, users can correct wrong attachments + return + + if not ref_doc.meta.protect_attached_files: + return + + if ref_doc.docstatus == 2 and ref_doc.has_permission("delete"): + # Deletion must still be possible if users have the permission to delete the linked document + return + + frappe.throw( + msg=_("This file is attached to a protected document and cannot be deleted."), + title=_("Protected File"), + ) + def _delete_file_on_disk(self): """If file not attached to any other record, delete it""" on_disk_file_not_shared = self.content_hash and not frappe.get_all( diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index 09faf7ce14..32e25ae29e 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -33,6 +33,7 @@ "column_break_21", "allow_copy", "make_attachments_public", + "protect_attached_files", "view_settings_section", "title_field", "show_title_field_in_link", @@ -407,6 +408,13 @@ "fieldtype": "Int", "label": "Grid Page Length", "non_negative": 1 + }, + { + "default": "0", + "description": "Users are only able to delete attached files if the document is either in draft or if the document is canceled and they are also able to delete the document.", + "fieldname": "protect_attached_files", + "fieldtype": "Check", + "label": "Protect Attached Files" } ], "hide_toolbar": 1, @@ -415,7 +423,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-02-21 20:16:50.501895", + "modified": "2025-03-27 18:22:32.618603", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form", @@ -439,4 +447,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 704a7edfef..3bbd874732 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -71,6 +71,7 @@ class CustomizeForm(Document): "Random", "By script", ] + protect_attached_files: DF.Check queue_in_background: DF.Check quick_entry: DF.Check search_fields: DF.Data | None @@ -728,6 +729,7 @@ doctype_properties = { "editable_grid": "Check", "max_attachments": "Int", "make_attachments_public": "Check", + "protect_attached_files": "Check", "track_changes": "Check", "track_views": "Check", "allow_auto_repeat": "Check", diff --git a/frappe/public/js/frappe/form/sidebar/attachments.js b/frappe/public/js/frappe/form/sidebar/attachments.js index 1613faa99b..5598649b5f 100644 --- a/frappe/public/js/frappe/form/sidebar/attachments.js +++ b/frappe/public/js/frappe/form/sidebar/attachments.js @@ -130,7 +130,7 @@ frappe.ui.form.Attachments = class Attachments { `; let remove_action = null; - if (frappe.model.can_write(this.frm.doctype, this.frm.name)) { + if (this.can_delete_attachment()) { remove_action = function (target_id) { frappe.confirm(__("Are you sure you want to delete the attachment?"), function () { let target_attachment = me @@ -156,6 +156,21 @@ frappe.ui.form.Attachments = class Attachments { .insertAfter(this.add_attachment_wrapper); } + can_delete_attachment() { + if (this.frm.meta.protect_attached_files) { + switch (this.frm.doc.docstatus) { + case 0: + return this.frm.has_perm("write"); + case 2: + return this.frm.has_perm("write") && this.frm.has_perm("delete"); + default: + return false; + } + } + + return this.frm.has_perm("write"); + } + get_file_url(attachment) { var file_url = attachment.file_url; if (!file_url) {