From 44b522859842e88ff153e5c9ba11d18db01ae13b Mon Sep 17 00:00:00 2001 From: Shllokkk <140623894+Shllokkk@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:34:33 +0530 Subject: [PATCH] feat: introduce standard and letter_head_for fields in letter head doctype (#38417) * feat: introduce standard and letter_head_for fields in letter head doctype * feat: introduce a module link field to letterhead doctype to support json creation * feat: make Letter Head importable via sync * test(Letter Head): fix the test_auto_image test case for letter head doctype * fix: make module field depend on standard field value * feat: introduce letter heads for standard reports * fix: letter heads for non-standard reports * fix: letter_head validation in report and letter head doctype edit access based on users * fix: correct validation for standard letter head creation --- frappe/core/doctype/report/report.js | 13 +++++++ frappe/core/doctype/report/report.json | 3 +- frappe/core/doctype/report/report.py | 24 +++++++++--- frappe/model/sync.py | 1 + .../doctype/letter_head/letter_head.js | 17 +++++++- .../doctype/letter_head/letter_head.json | 31 ++++++++++++++- .../doctype/letter_head/letter_head.py | 17 ++++++++ .../doctype/letter_head/test_letter_head.py | 39 +++++++++++++++++-- frappe/public/js/frappe/form/print_utils.js | 8 ++++ 9 files changed, 139 insertions(+), 14 deletions(-) diff --git a/frappe/core/doctype/report/report.js b/frappe/core/doctype/report/report.js index 525eac411b..ca4301ba83 100644 --- a/frappe/core/doctype/report/report.js +++ b/frappe/core/doctype/report/report.js @@ -66,6 +66,19 @@ frappe.ui.form.on("Report", { }, }; }); + + frm.set_query("letter_head", () => { + const filters = { + letter_head_for: "Report", + disabled: 0, + }; + + if (frm.doc.is_standard === "Yes") { + filters.standard = "Yes"; + } + + return { filters }; + }); }, ref_doctype: function (frm) { diff --git a/frappe/core/doctype/report/report.json b/frappe/core/doctype/report/report.json index ad8a758982..26faccc909 100644 --- a/frappe/core/doctype/report/report.json +++ b/frappe/core/doctype/report/report.json @@ -97,7 +97,6 @@ "label": "Disabled" }, { - "depends_on": "eval: doc.is_standard == \"No\"", "fieldname": "letter_head", "fieldtype": "Link", "label": "Default Letter Head", @@ -214,7 +213,7 @@ "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-03-31 14:42:49.829920", + "modified": "2026-04-10 00:03:15.212213", "modified_by": "Administrator", "module": "Core", "name": "Report", diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index c99b697d04..f8ef10e1e8 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -76,16 +76,15 @@ class Report(Document): if frappe.session.user != "Administrator": frappe.throw(_("Only Administrator can save a standard report. Please rename and save.")) - # Letter Head is visible only for non-standard reports. - # It should not remain set when it's invisible. - self.letter_head = None - if self.report_type == "Report Builder": self.update_report_json() if self.default_print_format and self.has_value_changed("default_print_format"): self.validate_default_print_format() + if self.letter_head and self.has_value_changed("letter_head"): + self.validate_letter_head() + def before_insert(self): self.set_doctype_roles() @@ -93,7 +92,6 @@ class Report(Document): self.export_doc() def before_export(self, doc): - doc.letter_head = None doc.prepared_report = 0 def on_trash(self): @@ -429,6 +427,22 @@ class Report(Document): ): frappe.throw(_("Selected Print Format is invalid for this Report.")) + def validate_letter_head(self): + letter_head = frappe.db.get_value( + "Letter Head", + self.letter_head, + ["letter_head_for", "standard", "disabled"], + as_dict=True, + ) + + if ( + not letter_head + or letter_head.letter_head_for != "Report" + or (self.is_standard == "Yes" and letter_head.standard != "Yes") + or letter_head.disabled + ): + frappe.throw(_("Selected Letter Head is invalid for this Report.")) + @frappe.whitelist() def toggle_disable(self, disable: bool): if not self.has_permission("write"): diff --git a/frappe/model/sync.py b/frappe/model/sync.py index 3fe383f162..72ec5012f0 100644 --- a/frappe/model/sync.py +++ b/frappe/model/sync.py @@ -41,6 +41,7 @@ IMPORTABLE_DOCTYPES = [ ("core", "server_script"), ("custom", "custom_field"), ("custom", "property_setter"), + ("printing", "letter_head"), ] diff --git a/frappe/printing/doctype/letter_head/letter_head.js b/frappe/printing/doctype/letter_head/letter_head.js index 7055b5f78a..0ce14b5e8d 100644 --- a/frappe/printing/doctype/letter_head/letter_head.js +++ b/frappe/printing/doctype/letter_head/letter_head.js @@ -6,7 +6,22 @@ frappe.ui.form.on("Letter Head", { frm.get_field("instructions").html(INSTRUCTIONS); }, - refresh: function (frm) { + refresh(frm) { + frm.set_intro(""); + frm.enable_save(); + + if (!frappe.boot.developer_mode) { + if (frm.is_new()) { + frm.toggle_enable("standard", false); + } + + if (!frm.is_new() && frm.doc.standard === "Yes") { + frm.set_intro(__("Please duplicate this to make changes")); + frm.set_read_only(); + frm.disable_save(); + } + } + frm.flag_public_attachments = true; }, diff --git a/frappe/printing/doctype/letter_head/letter_head.json b/frappe/printing/doctype/letter_head/letter_head.json index aa40d0a751..119a59120f 100644 --- a/frappe/printing/doctype/letter_head/letter_head.json +++ b/frappe/printing/doctype/letter_head/letter_head.json @@ -2,15 +2,18 @@ "actions": [], "allow_rename": 1, "autoname": "field:letter_head_name", - "creation": "2012-11-22 17:45:46", + "creation": "2026-04-07 12:33:33.368499", "doctype": "DocType", "document_type": "Setup", "engine": "InnoDB", "field_order": [ + "letter_head_for", "letter_head_name", + "module", "source", "footer_source", "column_break_3", + "standard", "disabled", "is_default", "letter_head_image_section", @@ -196,6 +199,30 @@ "fieldtype": "HTML", "label": "Instructions", "read_only": 1 + }, + { + "default": "No", + "fieldname": "standard", + "fieldtype": "Select", + "label": "Standard", + "options": "No\nYes", + "reqd": 1 + }, + { + "default": "DocType", + "fieldname": "letter_head_for", + "fieldtype": "Select", + "label": "Letter Head For", + "options": "DocType\nReport", + "reqd": 1 + }, + { + "depends_on": "eval: doc.standard == \"Yes\"", + "fieldname": "module", + "fieldtype": "Link", + "label": "Module", + "mandatory_depends_on": "eval: doc.standard == \"Yes\"", + "options": "Module Def" } ], "icon": "fa fa-font", @@ -203,7 +230,7 @@ "links": [], "make_attachments_public": 1, "max_attachments": 3, - "modified": "2026-02-25 14:37:57.061516", + "modified": "2026-04-08 13:15:24.935222", "modified_by": "Administrator", "module": "Printing", "name": "Letter Head", diff --git a/frappe/printing/doctype/letter_head/letter_head.py b/frappe/printing/doctype/letter_head/letter_head.py index a4daf3c7c6..220a09cac1 100644 --- a/frappe/printing/doctype/letter_head/letter_head.py +++ b/frappe/printing/doctype/letter_head/letter_head.py @@ -4,6 +4,7 @@ import frappe from frappe import _ from frappe.model.document import Document +from frappe.modules.utils import export_module_json from frappe.utils import flt, is_image @@ -31,8 +32,11 @@ class LetterHead(Document): image_height: DF.Float image_width: DF.Float is_default: DF.Check + letter_head_for: DF.Literal["DocType", "Report"] letter_head_name: DF.Data + module: DF.Link | None source: DF.Literal["Image", "HTML"] + standard: DF.Literal["No", "Yes"] # end: auto-generated types def before_insert(self): @@ -50,6 +54,7 @@ class LetterHead(Document): def validate(self): self.set_image() self.validate_disabled_and_default() + self.validate_standard_letter_head() def validate_disabled_and_default(self): if self.disabled and self.is_default: @@ -119,6 +124,7 @@ class LetterHead(Document): def on_update(self): self.set_as_default() + self.export_letter_head() # clear the cache so that the new letter head is uploaded frappe.clear_cache() @@ -136,3 +142,14 @@ class LetterHead(Document): else: frappe.defaults.clear_default("letter_head", self.name) frappe.defaults.clear_default("default_letter_head_content", self.content) + + def export_letter_head(self): + return export_module_json(self, self.standard == "Yes", self.module) + + def validate_standard_letter_head(self): + if self.standard == "Yes": + if not frappe.conf.developer_mode and not self.is_new() and not frappe.flags.in_migrate: + frappe.throw(_("Standard Letter Head can be updated in Developer Mode only.")) + + if not self.module: + frappe.throw(_("Module is required when Standard is set to 'Yes'")) diff --git a/frappe/printing/doctype/letter_head/test_letter_head.py b/frappe/printing/doctype/letter_head/test_letter_head.py index 8273515fc7..6a850f269b 100644 --- a/frappe/printing/doctype/letter_head/test_letter_head.py +++ b/frappe/printing/doctype/letter_head/test_letter_head.py @@ -1,14 +1,45 @@ # Copyright (c) 2017, Frappe Technologies and Contributors # License: MIT. See LICENSE +import os +import shutil + import frappe from frappe.tests import IntegrationTestCase class TestLetterHead(IntegrationTestCase): def test_auto_image(self): - letter_head = frappe.get_doc( - doctype="Letter Head", letter_head_name="Test", source="Image", image="/public/test.png" - ).insert() + doc = frappe.new_doc("Letter Head") + doc.letter_head_for = "DocType" + doc.letter_head_name = "Test Letter Head" + doc.module = "Core" + doc.standard = "No" + doc.source = "Image" + doc.image = "/public/test.png" + doc.insert() # test if image is automatically set - self.assertTrue(letter_head.image in letter_head.content) + self.assertTrue(doc.image in doc.content) + + def test_export_letter_head(self): + doc = frappe.new_doc("Letter Head") + doc.letter_head_for = "DocType" + doc.letter_head_name = "Test Letter Head Standard" + doc.module = "Core" + doc.standard = "No" + doc.insert() + + doc.standard = "Yes" + + dev_mode_before = frappe.conf.developer_mode + frappe.conf.developer_mode = True + + export_path = doc.export_letter_head() + + frappe.conf.developer_mode = dev_mode_before + + final_path = f"{export_path}.json" + self.assertTrue(os.path.exists(final_path)) + + dir_path = os.path.dirname(os.path.dirname(final_path)) + self.addCleanup(shutil.rmtree, dir_path) diff --git a/frappe/public/js/frappe/form/print_utils.js b/frappe/public/js/frappe/form/print_utils.js index 24d4a06f72..0bd344d145 100644 --- a/frappe/public/js/frappe/form/print_utils.js +++ b/frappe/public/js/frappe/form/print_utils.js @@ -57,6 +57,14 @@ frappe.ui.get_print_settings = function ( depends_on: "with_letter_head", options: "Letter Head", default: letter_head || default_letter_head, + get_query: () => { + return { + filters: { + letter_head_for: "Report", + disabled: 0, + }, + }; + }, }, ];