diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml
index ab2ebb0f45..b35427d7f7 100644
--- a/.github/workflows/server-tests.yml
+++ b/.github/workflows/server-tests.yml
@@ -74,7 +74,7 @@ jobs:
- name: Download artifacts
uses: actions/download-artifact@v8.0.1
- name: Upload coverage data
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@v6
with:
name: Server
token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml
index 6aa8572243..c533f36b7e 100644
--- a/.github/workflows/ui-tests.yml
+++ b/.github/workflows/ui-tests.yml
@@ -59,7 +59,7 @@ jobs:
- name: Download artifacts
uses: actions/download-artifact@v8.0.1
- name: Upload python coverage data
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@v6
with:
name: UIBackend
token: ${{ secrets.CODECOV_TOKEN }}
@@ -68,7 +68,7 @@ jobs:
exclude: coverage-js*
flags: server-ui
- name: Upload JS coverage data
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@v6
with:
name: Cypress
token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/frappe/api/v1.py b/frappe/api/v1.py
index 523c0d0f54..75bb9a7f3b 100644
--- a/frappe/api/v1.py
+++ b/frappe/api/v1.py
@@ -97,7 +97,8 @@ def get_values_for_link_and_dynamic_link_fields(doc_dict):
doctype = field.options if field.fieldtype == "Link" else doc_dict.get(field.options)
- link_doc = frappe.get_doc(doctype, doc_fieldvalue)
+ link_doc = frappe.get_doc(doctype, doc_fieldvalue, check_permission="read")
+ link_doc.apply_fieldlevel_read_permissions()
doc_dict.update({field.fieldname: link_doc})
diff --git a/frappe/auth.py b/frappe/auth.py
index 4267c60f73..347f6b4e3a 100644
--- a/frappe/auth.py
+++ b/frappe/auth.py
@@ -155,7 +155,9 @@ class LoginManager:
self.authenticate(user=user, pwd=pwd)
if self.force_user_to_reset_password():
doc = frappe.get_doc("User", self.user)
- frappe.local.response["redirect_to"] = doc.reset_password(send_email=False, password_expired=True)
+ frappe.local.response["redirect_to"] = doc._reset_password(
+ send_email=False, password_expired=True
+ )
frappe.local.response["message"] = "Password Reset"
return False
diff --git a/frappe/commands/testing.py b/frappe/commands/testing.py
index 78468b0fd3..394b4c29cf 100644
--- a/frappe/commands/testing.py
+++ b/frappe/commands/testing.py
@@ -159,11 +159,14 @@ def main(
discover_all_tests(apps, runner)
results = []
+ global unittest_runner
for app, category, suite in runner.iterRun():
click.secho(
f"\nRunning {suite.countTestCases()} {category} tests for {app}", fg="cyan", bold=True
)
- results.append([app, category, runner.run(suite)])
+ main_runner = unittest_runner if junit_xml_output and unittest_runner else runner
+ res = main_runner.run(suite)
+ results.append([app, category, res])
success = all(r.wasSuccessful() for _, _, r in results)
if not success:
diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py
index 60607fd11f..ca94bed751 100644
--- a/frappe/commands/utils.py
+++ b/frappe/commands/utils.py
@@ -108,6 +108,19 @@ def build(
print("Compiling translations for", app)
compile_translations(app, force=force)
+ run_after_build_hook(apps)
+
+
+def run_after_build_hook(apps):
+ from importlib import import_module
+
+ for app in apps:
+ for fn in frappe.get_hooks("after_build", app_name=app):
+ modulename = ".".join(fn.split(".")[:-1])
+ methodname = fn.split(".")[-1]
+ method = getattr(import_module(modulename), methodname)
+ method()
+
@click.command("watch")
@click.option("--apps", help="Watch assets for specific apps")
diff --git a/frappe/core/api/user_invitation.py b/frappe/core/api/user_invitation.py
index 5390caa154..1e685e6c28 100644
--- a/frappe/core/api/user_invitation.py
+++ b/frappe/core/api/user_invitation.py
@@ -143,7 +143,7 @@ def _accept_invitation(key: str, in_test: bool) -> None:
# set redirect_to
redirect_to = frappe.utils.get_url(invitation.get_redirect_to_path())
if should_update_password:
- redirect_to = f"{user.reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}"
+ redirect_to = f"{user._reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}"
# GET requests do not cause an implicit commit
frappe.db.commit() # nosemgrep
diff --git a/frappe/core/doctype/communication/mixins.py b/frappe/core/doctype/communication/mixins.py
index b2aaeb3979..318fa785ec 100644
--- a/frappe/core/doctype/communication/mixins.py
+++ b/frappe/core/doctype/communication/mixins.py
@@ -20,6 +20,22 @@ class CommunicationEmailMixin:
parent_doc = get_parent_doc(self)
return parent_doc.owner if parent_doc else None
+ def get_notification_recipient(self):
+ """Get notification recipient of the communication docs parent.
+
+ Calls `get_notification_email` on the parent if available; otherwise returns the owner.
+ This uses `run_method` so hooks can customize recipients per app/site.
+ """
+ parent_doc = get_parent_doc(self)
+ if not parent_doc:
+ return None
+
+ notification_email = parent_doc.run_method("get_notification_email")
+ if notification_email:
+ return notification_email
+
+ return parent_doc.owner
+
def get_all_email_addresses(self, exclude_displayname=False):
"""Get all Email addresses mentioned in the doc along with display name."""
return (
@@ -60,7 +76,7 @@ class CommunicationEmailMixin:
"""Build cc list to send an email.
* if email copy is requested by sender, then add sender to CC.
- * If this doc is created through inbound mail, then add doc owner to cc list
+ * If this doc is created through inbound mail, then add the notification recipient to CC
* remove all the thread_notify disabled users.
* Remove standard users from email list
"""
@@ -77,9 +93,9 @@ class CommunicationEmailMixin:
cc.append(sender)
if is_inbound_mail_communcation:
- # inform parent document owner incase communication is created through inbound mail
- if doc_owner := self.get_owner():
- cc.append(doc_owner)
+ # inform the configured notification recipient in case communication is created inbound
+ if notification_recipient := self.get_notification_recipient():
+ cc.append(notification_recipient)
cc = set(cc) - {self.sender_mailid}
assignees = set(self.get_assignees()) - {self.sender_mailid}
# Check and remove If user disabled notifications for incoming emails on assigned document.
diff --git a/frappe/core/doctype/custom_docperm/custom_docperm.json b/frappe/core/doctype/custom_docperm/custom_docperm.json
index 00a47a0113..2c8e5ee41e 100644
--- a/frappe/core/doctype/custom_docperm/custom_docperm.json
+++ b/frappe/core/doctype/custom_docperm/custom_docperm.json
@@ -227,7 +227,7 @@
],
"grid_page_length": 50,
"links": [],
- "modified": "2025-05-22 16:59:35.484376",
+ "modified": "2026-04-09 11:13:35.484376",
"modified_by": "Administrator",
"module": "Core",
"name": "Custom DocPerm",
@@ -239,6 +239,7 @@
"delete": 1,
"email": 1,
"export": 1,
+ "import": 1,
"print": 1,
"read": 1,
"report": 1,
diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py
index 3ddc070fc5..c356da366f 100644
--- a/frappe/core/doctype/data_import/importer.py
+++ b/frappe/core/doctype/data_import/importer.py
@@ -13,6 +13,7 @@ from frappe.core.doctype.version.version import get_diff
from frappe.model import no_value_fields
from frappe.utils import cint, cstr, duration_to_seconds, flt, update_progress_bar
from frappe.utils.csvutils import get_csv_content_from_google_sheets, read_csv_content
+from frappe.utils.data import escape_html
from frappe.utils.xlsxutils import (
read_xls_file_from_attached_file,
read_xlsx_file_from_attached_file,
@@ -727,7 +728,9 @@ class Row:
elif df.fieldtype == "Link":
exists = self.link_exists(value, df)
if not exists:
- msg = _("Value {0} missing for {1}").format(frappe.bold(value), frappe.bold(df.options))
+ msg = _("Value {0} missing for {1}").format(
+ frappe.bold(escape_html(cstr(value))), frappe.bold(df.options)
+ )
self.warnings.append(
{
"row": self.row_number,
@@ -746,7 +749,8 @@ class Row:
"col": col.column_number,
"field": df_as_json(df),
"message": _("Value {0} must in {1} format").format(
- frappe.bold(value), frappe.bold(get_user_format(col.date_format))
+ frappe.bold(escape_html(cstr(value))),
+ frappe.bold(get_user_format(col.date_format)),
),
}
)
@@ -761,7 +765,8 @@ class Row:
"col": col.column_number,
"field": df_as_json(df),
"message": _("Value {0} must in {1} format").format(
- frappe.bold(value), frappe.bold(get_user_format(col.date_format))
+ frappe.bold(escape_html(cstr(value))),
+ frappe.bold(get_user_format(col.date_format)),
),
}
)
@@ -774,7 +779,7 @@ class Row:
"col": col.column_number,
"field": df_as_json(df),
"message": _("Value {0} must be in the valid duration format: d h m s").format(
- frappe.bold(value)
+ frappe.bold(escape_html(cstr(value)))
),
}
)
@@ -1045,7 +1050,7 @@ class Column:
]
not_exists = list(set(values) - set(exists))
if not_exists:
- missing_values = ", ".join(not_exists)
+ missing_values = ", ".join(escape_html(v) for v in not_exists)
message = _("The following values do not exist for {0}: {1}")
self.warnings.append(
{
@@ -1088,7 +1093,7 @@ class Column:
invalid = values - set(options)
if invalid:
valid_values = ", ".join(frappe.bold(o) for o in options)
- invalid_values = ", ".join(frappe.bold(i) for i in invalid)
+ invalid_values = ", ".join(frappe.bold(escape_html(i)) for i in invalid)
message = _("The following values are invalid: {0}. Values must be one of {1}")
self.warnings.append(
{
diff --git a/frappe/core/doctype/file/file.js b/frappe/core/doctype/file/file.js
index 2dd4bb48a2..ee9c9de937 100644
--- a/frappe/core/doctype/file/file.js
+++ b/frappe/core/doctype/file/file.js
@@ -48,7 +48,13 @@ frappe.ui.form.on("File", {
const field = frm.get_field("attached_to_name");
field.$input_wrapper
.find(".control-value")
- .html(`${frappe.utils.get_form_link(frm.doctype, frm.docname, true)}`);
+ .html(
+ `${frappe.utils.get_form_link(
+ frm.doc.attached_to_doctype,
+ frm.doc.attached_to_name,
+ true
+ )}`
+ );
}
},
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(
diff --git a/frappe/core/doctype/package_release/package_release.py b/frappe/core/doctype/package_release/package_release.py
index b298a10d37..af2d83e820 100644
--- a/frappe/core/doctype/package_release/package_release.py
+++ b/frappe/core/doctype/package_release/package_release.py
@@ -93,8 +93,9 @@ class PackageRelease(Document):
def export_package_files(self, package):
# write readme
- with open(frappe.get_site_path("packages", package.package_name, "README.md"), "w") as readme:
- readme.write(package.readme)
+ if package.readme:
+ with open(frappe.get_site_path("packages", package.package_name, "README.md"), "w") as readme:
+ readme.write(package.readme)
# write license
if package.license:
diff --git a/frappe/core/doctype/report/report.js b/frappe/core/doctype/report/report.js
index 4f1c0092a1..525eac411b 100644
--- a/frappe/core/doctype/report/report.js
+++ b/frappe/core/doctype/report/report.js
@@ -55,6 +55,17 @@ frappe.ui.form.on("Report", {
},
};
});
+
+ frm.set_query("default_print_format", () => {
+ return {
+ filters: {
+ print_format_for: "Report",
+ report: frm.doc.name,
+ print_format_type: "JS",
+ disabled: 0,
+ },
+ };
+ });
},
ref_doctype: function (frm) {
diff --git a/frappe/core/doctype/report/report.json b/frappe/core/doctype/report/report.json
index 519184d57b..ad8a758982 100644
--- a/frappe/core/doctype/report/report.json
+++ b/frappe/core/doctype/report/report.json
@@ -14,6 +14,7 @@
"column_break_4",
"report_type",
"letter_head",
+ "default_print_format",
"add_total_row",
"disabled",
"prepared_report",
@@ -99,7 +100,7 @@
"depends_on": "eval: doc.is_standard == \"No\"",
"fieldname": "letter_head",
"fieldtype": "Link",
- "label": "Letter Head",
+ "label": "Default Letter Head",
"options": "Letter Head"
},
{
@@ -202,12 +203,18 @@
"fieldname": "add_translate_data",
"fieldtype": "Check",
"label": "Add Translate Data"
+ },
+ {
+ "fieldname": "default_print_format",
+ "fieldtype": "Link",
+ "label": "Default Print Format",
+ "options": "Print Format"
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2025-08-28 18:28:32.510719",
+ "modified": "2026-03-31 14:42:49.829920",
"modified_by": "Administrator",
"module": "Core",
"name": "Report",
diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py
index 1c08b3d533..c99b697d04 100644
--- a/frappe/core/doctype/report/report.py
+++ b/frappe/core/doctype/report/report.py
@@ -32,6 +32,7 @@ class Report(Document):
add_total_row: DF.Check
add_translate_data: DF.Check
columns: DF.Table[ReportColumn]
+ default_print_format: DF.Link | None
disabled: DF.Check
filters: DF.Table[ReportFilter]
is_standard: DF.Literal["No", "Yes"]
@@ -82,6 +83,9 @@ class Report(Document):
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()
+
def before_insert(self):
self.set_doctype_roles()
@@ -408,6 +412,23 @@ class Report(Document):
return data
+ def validate_default_print_format(self):
+ pf = frappe.db.get_value(
+ "Print Format",
+ self.default_print_format,
+ ["report", "print_format_for", "print_format_type", "disabled"],
+ as_dict=True,
+ )
+
+ if (
+ not pf
+ or pf.report != self.name
+ or pf.print_format_for != "Report"
+ or pf.print_format_type != "JS"
+ or pf.disabled
+ ):
+ frappe.throw(_("Selected Print Format is invalid for this Report."))
+
@frappe.whitelist()
def toggle_disable(self, disable: bool):
if not self.has_permission("write"):
diff --git a/frappe/core/doctype/translation/test_translation.py b/frappe/core/doctype/translation/test_translation.py
index 7a28d068d3..0da71d3dc8 100644
--- a/frappe/core/doctype/translation/test_translation.py
+++ b/frappe/core/doctype/translation/test_translation.py
@@ -16,16 +16,20 @@ class TestTranslation(IntegrationTestCase):
clear_cache()
def test_doctype(self):
- translation_data = get_translation_data()
- for lang, (source_string, new_translation) in translation_data.items():
+ doctype = "Translation"
+ meta = frappe.get_meta(doctype)
+ source_string = meta.get_label("translated_text")
+
+ for lang in ["de", "bs", "zh", "hr", "en", "sv"]:
frappe.local.lang = lang
- original_translation = _(source_string)
+ original_translation = _(source_string, context=doctype)
+ new_translation = f"{original_translation} Customized"
- docname = create_translation(lang, source_string, new_translation)
- self.assertEqual(_(source_string), new_translation)
+ docname = create_translation(lang, source_string, new_translation, context=doctype)
+ self.assertEqual(_(source_string, context=doctype), new_translation)
- frappe.delete_doc("Translation", docname)
- self.assertEqual(_(source_string), original_translation)
+ frappe.delete_doc(doctype, docname)
+ self.assertEqual(_(source_string, context=doctype), original_translation)
def test_parent_language(self):
data = {
@@ -60,37 +64,54 @@ class TestTranslation(IntegrationTestCase):
source = "User"
self.assertNotEqual(_(source, lang="de"), _(source, lang="es"))
- def test_html_content_data_translation(self):
- # ruff: noqa: RUF001
+ def test_html_content_translation(self):
source = """
- MacBook Air lasts up to an incredible 12 hours between charges. So from your morning coffee to
- your evening commute, you can work unplugged. When it’s time to kick back and relax,
- you can get up to 12 hours of iTunes movie playback. And with up to 30 days of standby time,
- you can go away for weeks and pick up where you left off.Whatever the task,
- fifth-generation Intel Core i5 and i7 processors with Intel HD Graphics 6000 are up to it.
- """
-
+ To add dynamic subject, use jinja tags like
+
{{ doc.name }} Billed{{ doc.name }} Abgerechnet