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
This commit is contained in:
Shllokkk 2026-04-17 12:34:33 +05:30 committed by GitHub
parent d6daefb3a3
commit 44b5228598
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 139 additions and 14 deletions

View file

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

View file

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

View file

@ -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"):

View file

@ -41,6 +41,7 @@ IMPORTABLE_DOCTYPES = [
("core", "server_script"),
("custom", "custom_field"),
("custom", "property_setter"),
("printing", "letter_head"),
]

View file

@ -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;
},

View file

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

View file

@ -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'"))

View file

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

View file

@ -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,
},
};
},
},
];