diff --git a/cypress/integration/utils.js b/cypress/integration/utils.js
index e30d922429..083a03294a 100644
--- a/cypress/integration/utils.js
+++ b/cypress/integration/utils.js
@@ -49,6 +49,24 @@ context("Utils", () => {
seconds: 0,
});
});
+
+ run_util("seconds_to_duration", 60 * 60, { hide_seconds: 1 }).then((duration) => {
+ expect(duration).to.deep.equal({
+ days: 0,
+ hours: 1,
+ minutes: 0,
+ seconds: 0,
+ });
+ });
+
+ run_util("seconds_to_duration", 15 * 60, { hide_seconds: 1 }).then((duration) => {
+ expect(duration).to.deep.equal({
+ days: 0,
+ hours: 0,
+ minutes: 15,
+ seconds: 0,
+ });
+ });
});
it("should parse days, hours, minutes and seconds", () => {
diff --git a/frappe/api/__init__.py b/frappe/api/__init__.py
index a8736db67e..db7f96da50 100644
--- a/frappe/api/__init__.py
+++ b/frappe/api/__init__.py
@@ -1,5 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
+from contextlib import suppress
from enum import Enum
from werkzeug.exceptions import NotFound
@@ -7,8 +8,8 @@ from werkzeug.routing import Map, Submount
from werkzeug.wrappers import Request, Response
import frappe
-import frappe.client
from frappe import _
+from frappe.pulse.app_heartbeat_event import capture_app_heartbeat
from frappe.utils.response import build_response
@@ -63,7 +64,12 @@ def handle(request: Request):
if data is not None:
frappe.response["data"] = data
- return build_response("json")
+ data = build_response("json")
+
+ with suppress(Exception):
+ capture_app_heartbeat(arguments)
+
+ return data
# Merge all API version routing rules
diff --git a/frappe/desk/doctype/number_card/number_card.js b/frappe/desk/doctype/number_card/number_card.js
index 274ca76848..c474489640 100644
--- a/frappe/desk/doctype/number_card/number_card.js
+++ b/frappe/desk/doctype/number_card/number_card.js
@@ -26,6 +26,7 @@ frappe.ui.form.on("Number Card", {
frm.trigger("render_filters_table");
}
frm.trigger("set_parent_document_type");
+ frm.trigger("set_document_type_description");
if (!frm.is_new()) {
frm.trigger("create_add_to_dashboard_button");
@@ -67,6 +68,8 @@ frappe.ui.form.on("Number Card", {
},
type: function (frm) {
+ frm.trigger("set_document_type_description");
+
if (frm.doc.type == "Report") {
frm.set_query("report_name", () => {
return {
@@ -202,7 +205,9 @@ frappe.ui.form.on("Number Card", {
let is_dynamic_filter = (f) => ["Date", "DateRange"].includes(f.fieldtype) && f.default;
let wrapper = $(frm.get_field("filters_json").wrapper).empty();
- let table = $(`
+ let table = $(`
| ${__("Filter")} |
@@ -212,7 +217,10 @@ frappe.ui.form.on("Number Card", {
`).appendTo(wrapper);
- $(`${__("Click table to edit")}
`).appendTo(wrapper);
+
+ if (frm.has_perm("write")) {
+ $(`${__("Click table to edit")}
`).appendTo(wrapper);
+ }
let filters = JSON.parse(frm.doc.filters_json || "[]");
let filters_set = false;
@@ -273,6 +281,10 @@ frappe.ui.form.on("Number Card", {
}
table.on("click", () => {
+ if (!frm.has_perm("write")) {
+ return;
+ }
+
if (!frappe.boot.developer_mode && frm.doc.is_standard) {
frappe.throw(__("Cannot edit filters for standard number cards"));
}
@@ -332,8 +344,9 @@ frappe.ui.form.on("Number Card", {
let wrapper = $(frm.get_field("dynamic_filters_json").wrapper).empty();
- frm.dynamic_filter_table =
- $(`
+ frm.dynamic_filter_table = $(`
| ${__("Filter")} |
@@ -360,6 +373,10 @@ frappe.ui.form.on("Number Card", {
);
frm.dynamic_filter_table.on("click", () => {
+ if (!frm.has_perm("write")) {
+ return;
+ }
+
if (!frappe.boot.developer_mode && frm.doc.is_standard) {
frappe.throw(__("Cannot edit filters for standard number cards"));
}
@@ -454,4 +471,20 @@ frappe.ui.form.on("Number Card", {
}
}
},
+
+ set_document_type_description: function (frm) {
+ if (frm.doc.type == "Custom") {
+ frm.set_df_property(
+ "document_type",
+ "description",
+ __(
+ "This card is visible only to Administrator and System Managers by default. Set a DocType to share with users who have read access.",
+ null,
+ "Number Card"
+ )
+ );
+ } else {
+ frm.set_df_property("document_type", "description", "");
+ }
+ },
});
diff --git a/frappe/desk/doctype/number_card/number_card.json b/frappe/desk/doctype/number_card/number_card.json
index 39a6f99092..089786fbdc 100644
--- a/frappe/desk/doctype/number_card/number_card.json
+++ b/frappe/desk/doctype/number_card/number_card.json
@@ -38,7 +38,7 @@
],
"fields": [
{
- "depends_on": "eval: doc.type == 'Document Type'",
+ "depends_on": "eval: ['Document Type', 'Custom'].includes(doc.type)",
"fieldname": "document_type",
"fieldtype": "Link",
"in_list_view": 1,
@@ -229,7 +229,7 @@
}
],
"links": [],
- "modified": "2025-05-21 17:33:04.908518",
+ "modified": "2025-09-17 21:00:11.351605",
"modified_by": "Administrator",
"module": "Desk",
"name": "Number Card",
diff --git a/frappe/desk/doctype/number_card/number_card.py b/frappe/desk/doctype/number_card/number_card.py
index 2fb76958dc..29a3e144bf 100644
--- a/frappe/desk/doctype/number_card/number_card.py
+++ b/frappe/desk/doctype/number_card/number_card.py
@@ -7,9 +7,10 @@ from frappe.boot import get_allowed_report_names
from frappe.model.document import Document
from frappe.model.naming import append_number_if_name_exists
from frappe.modules.export_file import export_to_files
+from frappe.permissions import get_doctypes_with_read
from frappe.query_builder import Criterion
from frappe.query_builder.utils import DocType
-from frappe.utils import cint, flt
+from frappe.utils import flt
from frappe.utils.modules import get_modules_from_all_apps_for_user
@@ -78,51 +79,43 @@ class NumberCard(Document):
def get_permission_query_conditions(user=None):
- if not user:
- user = frappe.session.user
-
- if user == "Administrator":
+ # The user param is ignored because `get_allowed_report_names` and `get_doctypes_with_read` don't support it.
+ if frappe.session.user == "Administrator":
return
- roles = frappe.get_roles(user)
- if "System Manager" in roles:
- return None
+ if "System Manager" in frappe.get_roles():
+ return
- doctype_condition = False
- module_condition = False
+ allowed_reports = get_allowed_report_names()
+ allowed_doctypes = get_doctypes_with_read()
+ allowed_modules = [module.get("module_name") for module in get_modules_from_all_apps_for_user()]
- allowed_doctypes = [frappe.db.escape(doctype) for doctype in frappe.permissions.get_doctypes_with_read()]
- allowed_modules = [
- frappe.db.escape(module.get("module_name")) for module in get_modules_from_all_apps_for_user()
- ]
+ nc = frappe.qb.DocType("Number Card")
+ conditions = (
+ ((nc.type == "Report") & nc.report_name.isin(allowed_reports))
+ | ((nc.type == "Custom") & nc.document_type.isin(allowed_doctypes))
+ | ((nc.type == "Document Type") & nc.document_type.isin(allowed_doctypes))
+ ) & (nc.module.isin(allowed_modules) | nc.module.isnull() | nc.module == "")
- if allowed_doctypes:
- doctype_condition = "`tabNumber Card`.`document_type` in ({allowed_doctypes})".format(
- allowed_doctypes=",".join(allowed_doctypes)
- )
- if allowed_modules:
- module_condition = """`tabNumber Card`.`module` in ({allowed_modules})
- or `tabNumber Card`.`module` is NULL""".format(allowed_modules=",".join(allowed_modules))
-
- return f"""
- {doctype_condition}
- and
- {module_condition}
- """
+ return conditions.get_sql(quote_char="`")
def has_permission(doc, ptype, user):
- roles = frappe.get_roles(user)
- if "System Manager" in roles:
+ # The user param is ignored because `get_allowed_report_names` and `get_doctypes_with_read` don't support it.
+ if frappe.session.user == "Administrator":
return True
- if doc.type == "Report":
- if doc.report_name in get_allowed_report_names():
- return True
- else:
- allowed_doctypes = tuple(frappe.permissions.get_doctypes_with_read())
- if doc.document_type in allowed_doctypes:
- return True
+ if "System Manager" in frappe.get_roles():
+ return True
+
+ if doc.type == "Report" and doc.report_name in get_allowed_report_names():
+ return True
+
+ if doc.type == "Custom" and doc.document_type in get_doctypes_with_read():
+ return True
+
+ if doc.type == "Document Type" and doc.document_type in get_doctypes_with_read():
+ return True
return False
diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py
index 5c3f20c1e3..e403f9db86 100644
--- a/frappe/desk/query_report.py
+++ b/frappe/desk/query_report.py
@@ -199,10 +199,11 @@ def run(
is_tree=False,
parent_field=None,
are_default_filters=True,
+ js_filters=None,
):
if not user:
user = frappe.session.user
- validate_filters_permissions(report_name, filters, user)
+ validate_filters_permissions(report_name, filters, user, js_filters)
report = get_report_doc(report_name)
if not frappe.has_permission(report.ref_doctype, "report"):
frappe.msgprint(
@@ -339,9 +340,8 @@ def export_query():
)
frappe.msgprint(
_(
- "Your report is being generated in the background. "
- "You will receive an email on {0} with a download link once it is ready.".format(user_email)
- )
+ "Your report is being generated in the background. You will receive an email on {0} with a download link once it is ready."
+ ).format(user_email)
)
return
@@ -416,7 +416,7 @@ def _export_query(form_params, csv_params, populate_response=True):
if not populate_response:
return report_name, file_extension, content
- provide_binary_file(report_name, file_extension, content)
+ provide_binary_file(_(report_name), file_extension, content)
def valid_report_name(report_name, suffix):
@@ -905,25 +905,34 @@ def get_user_match_filters(doctypes, user):
return match_filters
-def validate_filters_permissions(report_name, filters=None, user=None):
+def validate_filters_permissions(report_name, filters=None, user=None, js_filters=None):
if not filters:
return
+ if js_filters is None:
+ js_filters = []
+
+ if isinstance(js_filters, str):
+ js_filters = json.loads(js_filters)
+
if isinstance(filters, str):
filters = json.loads(filters)
report = frappe.get_doc("Report", report_name)
- for field in report.filters:
- if field.fieldname in filters and field.fieldtype == "Link":
- linked_doctype = field.options
+
+ for field in report.filters + js_filters:
+ if hasattr(field, "as_dict"):
+ field = field.as_dict()
+ if field.get("fieldname") in filters and field.get("fieldtype") == "Link":
+ linked_doctype = field.get("options")
if not has_permission(
- doctype=linked_doctype, ptype="read", doc=filters[field.fieldname], user=user
+ doctype=linked_doctype, ptype="read", doc=filters[field.get("fieldname")], user=user
) and not has_permission(
- doctype=linked_doctype, ptype="select", doc=filters[field.fieldname], user=user
+ doctype=linked_doctype, ptype="select", doc=filters[field.get("fieldname")], user=user
):
frappe.throw(
_("You do not have permission to access {0}: {1}.").format(
- linked_doctype, filters[field.fieldname]
+ linked_doctype, filters[field.get("fieldname")]
)
)
diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py
index 43e8ff5c30..a4dc3c8382 100644
--- a/frappe/desk/reportview.py
+++ b/frappe/desk/reportview.py
@@ -395,9 +395,8 @@ def export_query():
frappe.msgprint(
_(
- "Your report is being generated in the background. "
- "You will receive an email on {0} with a download link once it is ready.".format(user_email)
- )
+ "Your report is being generated in the background. You will receive an email on {0} with a download link once it is ready."
+ ).format(user_email)
)
return
@@ -484,7 +483,7 @@ def _export_query(form_params, csv_params, populate_response=True):
if not populate_response:
return title, file_extension, content
- provide_binary_file(title, file_extension, content)
+ provide_binary_file(_(title), file_extension, content)
def append_totals_row(data):
diff --git a/frappe/geo/languages.csv b/frappe/geo/languages.csv
index 0a1a3d9dae..ce220cd3ee 100644
--- a/frappe/geo/languages.csv
+++ b/frappe/geo/languages.csv
@@ -53,6 +53,7 @@ mn,Монгол,0
mr,मराठी,0
ms,Melayu,0
my,မြန်မာ,0
+nb,Norsk Bokmål,1
nl,Nederlands,0
no,Norsk,0
pl,Polski,0
diff --git a/frappe/hooks.py b/frappe/hooks.py
index 89aaf81002..d4de0d0047 100644
--- a/frappe/hooks.py
+++ b/frappe/hooks.py
@@ -209,6 +209,7 @@ scheduler_events = {
"frappe.automation.doctype.reminder.reminder.send_reminders",
"frappe.model.utils.link_count.update_link_count",
"frappe.search.sqlite_search.build_index_if_not_exists",
+ "frappe.pulse.client.send_queued_events",
],
# 10 minutes
"0/10 * * * *": [
diff --git a/frappe/locale/bs.po b/frappe/locale/bs.po
index 376f215bb1..504a1b0d3f 100644
--- a/frappe/locale/bs.po
+++ b/frappe/locale/bs.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2025-09-14 09:32+0000\n"
-"PO-Revision-Date: 2025-09-15 18:13\n"
+"PO-Revision-Date: 2025-09-17 18:30\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Bosnian\n"
"MIME-Version: 1.0\n"
@@ -5454,7 +5454,7 @@ msgstr "Kontakt"
#: frappe/integrations/doctype/google_calendar/google_calendar.py:812
msgid "Contact / email not found. Did not add attendee for -
{0}"
-msgstr ""
+msgstr "Kontakt/e-pošta nije pronađena. Nije dodan učesnik za -
{0}"
#. Label of the sb_01 (Section Break) field in DocType 'Contact'
#: frappe/contacts/doctype/contact/contact.json
@@ -13679,7 +13679,7 @@ msgstr "Je Primarno"
#: frappe/contacts/report/addresses_and_contacts/addresses_and_contacts.py:43
msgid "Is Primary Address"
-msgstr ""
+msgstr "Primarna Adresa"
#. Label of the is_primary_contact (Check) field in DocType 'Contact'
#: frappe/contacts/doctype/contact/contact.json
@@ -26298,7 +26298,7 @@ msgstr "Ovaj Mjesec"
#: frappe/core/doctype/file/file.py:394
msgid "This PDF cannot be uploaded as it contains unsafe content."
-msgstr ""
+msgstr "Ovaj PDF se ne može prenijeti jer sadrži nesiguran sadržaj."
#: frappe/public/js/frappe/ui/filters/filter.js:670
msgid "This Quarter"
diff --git a/frappe/locale/de.po b/frappe/locale/de.po
index 5dddcf7ce9..f4bb60cfb6 100644
--- a/frappe/locale/de.po
+++ b/frappe/locale/de.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2025-09-14 09:32+0000\n"
-"PO-Revision-Date: 2025-09-15 18:13\n"
+"PO-Revision-Date: 2025-09-18 19:07\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: German\n"
"MIME-Version: 1.0\n"
@@ -9622,7 +9622,7 @@ msgstr "Import-Log exportieren"
#: frappe/public/js/frappe/views/reports/report_utils.js:245
msgctxt "Export report"
msgid "Export Report: {0}"
-msgstr "Exportbericht: {0}"
+msgstr "Bericht exportieren: {0}"
#: frappe/public/js/frappe/data_import/data_exporter.js:26
msgid "Export Type"
@@ -21076,7 +21076,7 @@ msgstr "Neu verknüpfen"
#: frappe/core/doctype/communication/communication.js:138
msgid "Relink Communication"
-msgstr "Relink Kommunikation"
+msgstr "Kommunikation neu verknüpfen"
#. Option for the 'Comment Type' (Select) field in DocType 'Comment'
#: frappe/core/doctype/comment/comment.json
diff --git a/frappe/locale/fr.po b/frappe/locale/fr.po
index 065c70bed7..f34dff706c 100644
--- a/frappe/locale/fr.po
+++ b/frappe/locale/fr.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2025-09-14 09:32+0000\n"
-"PO-Revision-Date: 2025-09-15 18:13\n"
+"PO-Revision-Date: 2025-09-17 18:29\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: French\n"
"MIME-Version: 1.0\n"
@@ -4340,7 +4340,7 @@ msgstr ""
#. Label of the changed_values (HTML) field in DocType 'Permission Log'
#: frappe/core/doctype/permission_log/permission_log.json
msgid "Changes"
-msgstr ""
+msgstr "Modifications"
#: frappe/email/doctype/email_domain/email_domain.js:5
msgid "Changing any setting will reflect on all the email accounts associated with this domain."
@@ -8002,7 +8002,7 @@ msgstr "Vous n'avez pas de compte?"
#: frappe/public/js/print_format_builder/HTMLEditor.vue:5
#: frappe/public/js/print_format_builder/LetterHeadEditor.vue:52
msgid "Done"
-msgstr ""
+msgstr "Terminé"
#. Option for the 'Type' (Select) field in DocType 'Dashboard Chart'
#: frappe/desk/doctype/dashboard_chart/dashboard_chart.json
diff --git a/frappe/locale/hr.po b/frappe/locale/hr.po
index c7625485fc..a535b4030a 100644
--- a/frappe/locale/hr.po
+++ b/frappe/locale/hr.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2025-09-14 09:32+0000\n"
-"PO-Revision-Date: 2025-09-15 18:13\n"
+"PO-Revision-Date: 2025-09-17 18:30\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Croatian\n"
"MIME-Version: 1.0\n"
@@ -5454,7 +5454,7 @@ msgstr "Kontakt"
#: frappe/integrations/doctype/google_calendar/google_calendar.py:812
msgid "Contact / email not found. Did not add attendee for -
{0}"
-msgstr ""
+msgstr "Kontakt/e-pošta nije pronađena. Nije dodan sudionik za -
{0}"
#. Label of the sb_01 (Section Break) field in DocType 'Contact'
#: frappe/contacts/doctype/contact/contact.json
@@ -13679,7 +13679,7 @@ msgstr "Je Primarno"
#: frappe/contacts/report/addresses_and_contacts/addresses_and_contacts.py:43
msgid "Is Primary Address"
-msgstr ""
+msgstr "Primarna Adresa"
#. Label of the is_primary_contact (Check) field in DocType 'Contact'
#: frappe/contacts/doctype/contact/contact.json
@@ -26298,7 +26298,7 @@ msgstr "Ovaj Mjesec"
#: frappe/core/doctype/file/file.py:394
msgid "This PDF cannot be uploaded as it contains unsafe content."
-msgstr ""
+msgstr "Ovaj PDF se ne može prenijeti jer sadrži nesiguran sadržaj."
#: frappe/public/js/frappe/ui/filters/filter.js:670
msgid "This Quarter"
diff --git a/frappe/locale/nb.po b/frappe/locale/nb.po
index 49cb11ebb8..4346c51726 100644
--- a/frappe/locale/nb.po
+++ b/frappe/locale/nb.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2025-09-14 09:32+0000\n"
-"PO-Revision-Date: 2025-09-15 18:12\n"
+"PO-Revision-Date: 2025-09-18 19:07\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Norwegian Bokmal\n"
"MIME-Version: 1.0\n"
@@ -4240,11 +4240,11 @@ msgstr "Kan ikke aktivere {0} for en dokumenttype som ikke kan registreres"
#: frappe/core/doctype/file/file.py:262
msgid "Cannot find file {} on disk"
-msgstr ""
+msgstr "Kan ikke finne filen {} på disken"
#: frappe/core/doctype/file/file.py:581
msgid "Cannot get file contents of a Folder"
-msgstr ""
+msgstr "Kan ikke hente filinnholdet i en mappe"
#: frappe/printing/page/print/print.js:884
msgid "Cannot have multiple printers mapped to a single print format."
@@ -4252,7 +4252,7 @@ msgstr "Kan ikke ha flere skrivere tilordnet til ett og samme utskriftsformat."
#: frappe/public/js/frappe/form/grid.js:1133
msgid "Cannot import table with more than 5000 rows."
-msgstr ""
+msgstr "Kan ikke importere tabell med mer enn 5000 rader."
#: frappe/model/document.py:1105
msgid "Cannot link cancelled document: {0}"
@@ -4260,19 +4260,19 @@ msgstr "Kan ikke lenke til avbrutt dokument: {0}"
#: frappe/model/mapper.py:175
msgid "Cannot map because following condition fails:"
-msgstr ""
+msgstr "Kan ikke mappe fordi følgende betingelse mislykkes:"
#: frappe/core/doctype/data_import/importer.py:971
msgid "Cannot match column {0} with any field"
-msgstr ""
+msgstr "Kan ikke matche kolonnen {0} med noe felt"
#: frappe/public/js/frappe/form/grid_row.js:175
msgid "Cannot move row"
-msgstr ""
+msgstr "Kan ikke flytte rad"
#: frappe/public/js/frappe/views/reports/report_view.js:932
msgid "Cannot remove ID field"
-msgstr ""
+msgstr "Kan ikke fjerne ID-feltet"
#: frappe/core/page/permission_manager/permission_manager.py:132
msgid "Cannot set 'Report' permission if 'Only If Creator' permission is set"
@@ -4293,19 +4293,19 @@ msgstr "Kan ikke registrere {0}."
#: frappe/desk/doctype/bulk_update/bulk_update.js:26
#: frappe/public/js/frappe/list/bulk_operations.js:366
msgid "Cannot update {0}"
-msgstr ""
+msgstr "Kan ikke oppdatere {0}"
#: frappe/model/db_query.py:1130
msgid "Cannot use sub-query here."
-msgstr ""
+msgstr "Kan ikke bruke underspørsmål her."
#: frappe/model/db_query.py:1162
msgid "Cannot use {0} in order/group by"
-msgstr ""
+msgstr "Kan ikke bruke {0} i rekkefølge/gruppe etter"
#: frappe/public/js/frappe/list/bulk_operations.js:297
msgid "Cannot {0} {1}."
-msgstr ""
+msgstr "Kan ikke {0} {1}."
#: frappe/utils/password_strength.py:181
msgid "Capitalization doesn't help very much."
@@ -4313,12 +4313,12 @@ msgstr "Store bokstaver hjelper ikke noe særlig."
#: frappe/public/js/frappe/ui/capture.js:294
msgid "Capture"
-msgstr ""
+msgstr "Ta bilde"
#. Label of the card (Link) field in DocType 'Number Card Link'
#: frappe/desk/doctype/number_card_link/number_card_link.json
msgid "Card"
-msgstr ""
+msgstr "Kort"
#. Option for the 'Type' (Select) field in DocType 'Workspace Link'
#: frappe/desk/doctype/workspace_link/workspace_link.json
@@ -4327,11 +4327,11 @@ msgstr "Kortskille"
#: frappe/public/js/frappe/views/reports/query_report.js:262
msgid "Card Label"
-msgstr ""
+msgstr "Kortetikett"
#: frappe/public/js/frappe/widgets/widget_dialog.js:262
msgid "Card Links"
-msgstr ""
+msgstr "Kortlenker"
#. Label of the cards (Table) field in DocType 'Dashboard'
#: frappe/desk/doctype/dashboard/dashboard.json
@@ -4344,12 +4344,12 @@ msgstr "Kort"
#: frappe/public/js/frappe/views/interaction.js:72
#: frappe/website/doctype/help_article/help_article.json
msgid "Category"
-msgstr ""
+msgstr "Kategori"
#. Label of the category_description (Text) field in DocType 'Help Category'
#: frappe/website/doctype/help_category/help_category.json
msgid "Category Description"
-msgstr ""
+msgstr "Kategoribeskrivelse"
#. Label of the category_name (Data) field in DocType 'Help Category'
#: frappe/website/doctype/help_category/help_category.json
@@ -4421,20 +4421,20 @@ msgstr "Endret av"
#. Name of a DocType
#: frappe/desk/doctype/changelog_feed/changelog_feed.json
msgid "Changelog Feed"
-msgstr ""
+msgstr "Endringslogg"
#. Label of the changed_values (HTML) field in DocType 'Permission Log'
#: frappe/core/doctype/permission_log/permission_log.json
msgid "Changes"
-msgstr ""
+msgstr "Endringer"
#: frappe/email/doctype/email_domain/email_domain.js:5
msgid "Changing any setting will reflect on all the email accounts associated with this domain."
-msgstr ""
+msgstr "Hvis du endrer en innstilling, vil det påvirke alle e-postkontoer som er knyttet til dette domenet."
#: frappe/core/doctype/system_settings/system_settings.js:67
msgid "Changing rounding method on site with data can result in unexpected behaviour."
-msgstr ""
+msgstr "Endring av avrundingsmetode på stedet med data kan føre til uventet oppførsel."
#. Label of the channel (Select) field in DocType 'Notification'
#: frappe/email/doctype/notification/notification.json
@@ -4449,7 +4449,7 @@ msgstr "Diagram"
#. Label of the chart_config (Code) field in DocType 'Dashboard Settings'
#: frappe/desk/doctype/dashboard_settings/dashboard_settings.json
msgid "Chart Configuration"
-msgstr ""
+msgstr "Konfigurasjon av diagram"
#. Label of the chart_name (Data) field in DocType 'Dashboard Chart'
#. Label of the chart_name (Link) field in DocType 'Workspace Chart'
@@ -4466,12 +4466,12 @@ msgstr "Diagram-navn"
#: frappe/desk/doctype/dashboard/dashboard.json
#: frappe/desk/doctype/dashboard_chart/dashboard_chart.json
msgid "Chart Options"
-msgstr ""
+msgstr "Alternativer for diagram"
#. Label of the source (Link) field in DocType 'Dashboard Chart'
#: frappe/desk/doctype/dashboard_chart/dashboard_chart.json
msgid "Chart Source"
-msgstr ""
+msgstr "Kilde for diagram"
#. Label of the chart_type (Select) field in DocType 'Dashboard Chart'
#: frappe/desk/doctype/dashboard_chart/dashboard_chart.json
@@ -4484,7 +4484,7 @@ msgstr "Diagramtype"
#: frappe/desk/doctype/dashboard/dashboard.json
#: frappe/desk/doctype/workspace/workspace.json
msgid "Charts"
-msgstr ""
+msgstr "Diagrammer"
#. Option for the 'Type' (Select) field in DocType 'Communication'
#: frappe/core/doctype/communication/communication.json
@@ -4522,7 +4522,7 @@ msgstr "Sjekk feilloggen for mer informasjon: {0}"
#: frappe/website/doctype/website_settings/website_settings.js:147
msgid "Check this if you don't want users to sign up for an account on your site. Users won't get desk access unless you explicitly provide it."
-msgstr ""
+msgstr "Merk av for dette hvis du ikke vil at brukerne skal registrere seg for en konto på nettstedet ditt. Brukerne får ikke skrivebordstilgang med mindre du eksplisitt gir dem det."
#. Description of the 'User must always select' (Check) field in DocType
#. 'Document Naming Settings'
@@ -4533,52 +4533,52 @@ msgstr "Marker denne hvis du vil tvinge brukeren til å velge en serie før du l
#. Description of the 'Show Full Number' (Check) field in DocType 'Number Card'
#: frappe/desk/doctype/number_card/number_card.json
msgid "Check to display the full numeric value (e.g., 1,234,567 instead of 1.2M)."
-msgstr ""
+msgstr "Merk av for å vise hele tallverdien (f.eks. 1 234 567 i stedet for 1,2 millioner)."
#: frappe/public/js/frappe/desk.js:235
msgid "Checking one moment"
-msgstr ""
+msgstr "Sjekker ett øyeblikk"
#: frappe/website/doctype/website_settings/website_settings.js:140
msgid "Checking this will enable tracking page views for blogs, web pages, etc."
-msgstr ""
+msgstr "Hvis du merker av for dette, kan du spore sidevisninger for blogger, nettsider osv."
#. Description of the 'Hide Custom DocTypes and Reports' (Check) field in
#. DocType 'Workspace'
#: frappe/desk/doctype/workspace/workspace.json
msgid "Checking this will hide custom doctypes and reports cards in Links section"
-msgstr ""
+msgstr "Hvis du merker av for dette, skjules egendefinerte DocType-er og rapportkort i Links-delen"
#: frappe/website/doctype/web_page/web_page.js:78
msgid "Checking this will publish the page on your website and it'll be visible to everyone."
-msgstr ""
+msgstr "Hvis du merker av for dette, publiseres siden på nettstedet ditt, og den blir synlig for alle."
#: frappe/website/doctype/web_page/web_page.js:104
msgid "Checking this will show a text area where you can write custom javascript that will run on this page."
-msgstr ""
+msgstr "Hvis du merker av for dette, vises et tekstområde der du kan skrive egendefinert javascript som skal kjøres på denne siden."
#: frappe/www/list.py:85
msgid "Child DocTypes are not allowed"
-msgstr ""
+msgstr "Underordnede DocType-er er ikke tillatt"
#. Label of the child_doctype (Data) field in DocType 'Form Tour Step'
#: frappe/desk/doctype/form_tour_step/form_tour_step.json
msgid "Child Doctype"
-msgstr ""
+msgstr "Underordnet DocType"
#: frappe/core/doctype/doctype/doctype.py:1648
msgid "Child Table {0} for field {1}"
-msgstr ""
+msgstr "Underordnet tabell {0} for feltet {1}"
#. Description of the 'Is Child Table' (Check) field in DocType 'DocType'
#: frappe/core/doctype/doctype/doctype.json
#: frappe/core/doctype/doctype/doctype_list.js:52
msgid "Child Tables are shown as a Grid in other DocTypes"
-msgstr ""
+msgstr "Underordnede tabeller vises som et rutenett i andre DocType-er"
#: frappe/database/query.py:660
msgid "Child query fields for '{0}' must be a list or tuple."
-msgstr ""
+msgstr "Underordnede spørringsfelt for '{0}' må være en liste eller en tupel."
#: frappe/public/js/frappe/widgets/widget_dialog.js:651
msgid "Choose Existing Card or create New Card"
@@ -4586,17 +4586,17 @@ msgstr "Velg eksisterende kort eller opprett nytt kort"
#: frappe/public/js/frappe/views/workspace/workspace.js:571
msgid "Choose a block or continue typing"
-msgstr ""
+msgstr "Velg en blokk eller fortsett å skrive"
#: frappe/public/js/form_builder/components/controls/DataControl.vue:18
#: frappe/public/js/frappe/form/controls/color.js:5
msgid "Choose a color"
-msgstr ""
+msgstr "Velg en farge"
#: frappe/public/js/form_builder/components/controls/DataControl.vue:21
#: frappe/public/js/frappe/form/controls/icon.js:5
msgid "Choose an icon"
-msgstr ""
+msgstr "Velg et ikon"
#. Description of the 'Two Factor Authentication method' (Select) field in
#. DocType 'System Settings'
@@ -4688,7 +4688,7 @@ msgstr "Klikk på knappen for å logge inn på {0}"
#: frappe/templates/emails/data_deletion_approval.html:2
msgid "Click on the link below to approve the request"
-msgstr ""
+msgstr "Klikk på lenken nedenfor for å godkjenne forespørselen"
#: frappe/templates/emails/new_user.html:7
msgid "Click on the link below to complete your registration and set a new password"
@@ -4700,7 +4700,7 @@ msgstr "Klikk på lenken nedenfor for å laste ned dataene dine"
#: frappe/templates/emails/delete_data_confirmation.html:4
msgid "Click on the link below to verify your request"
-msgstr ""
+msgstr "Klikk på lenken nedenfor for å bekrefte forespørselen din"
#: frappe/integrations/doctype/google_calendar/google_calendar.py:118
#: frappe/integrations/doctype/google_contacts/google_contacts.py:41
@@ -4728,7 +4728,7 @@ msgstr "Klikk for å angi filtre"
#: frappe/public/js/frappe/list/list_view.js:739
msgid "Click to sort by {0}"
-msgstr ""
+msgstr "Klikk for å sortere etter {0}"
#. Option for the 'Delivery Status' (Select) field in DocType 'Communication'
#: frappe/core/doctype/communication/communication.json
@@ -5097,7 +5097,7 @@ msgstr "Kommentaroffentlighet kan bare oppdateres av den opprinnelige forfattere
#: frappe/public/js/frappe/model/model.js:135
#: frappe/website/doctype/web_form/templates/web_form.html:129
msgid "Comments"
-msgstr ""
+msgstr "Kommentarer"
#. Description of the 'Timeline Field' (Data) field in DocType 'DocType'
#: frappe/core/doctype/doctype/doctype.json
@@ -5470,12 +5470,12 @@ msgstr ""
#. Label of the phone_nos (Table) field in DocType 'Contact'
#: frappe/contacts/doctype/contact/contact.json
msgid "Contact Numbers"
-msgstr ""
+msgstr "Kontaktnummere"
#. Name of a DocType
#: frappe/contacts/doctype/contact_phone/contact_phone.json
msgid "Contact Phone"
-msgstr ""
+msgstr "Kontakttelefon"
#: frappe/integrations/doctype/google_contacts/google_contacts.py:291
msgid "Contact Synced with Google Contacts."
@@ -5591,15 +5591,15 @@ msgstr "Kontrollerer om nye brukere kan registrere seg med denne sosiale pålogg
#: frappe/public/js/frappe/utils/utils.js:1036
msgid "Copied to clipboard."
-msgstr ""
+msgstr "Kopier til utklippstavlen"
#: frappe/public/js/frappe/form/templates/timeline_message_box.html:93
msgid "Copy Link"
-msgstr ""
+msgstr "Kopier lenke"
#: frappe/website/doctype/web_form/web_form.js:29
msgid "Copy embed code"
-msgstr ""
+msgstr "Kopier innbyggingskode"
#: frappe/public/js/frappe/request.js:621
msgid "Copy error to clipboard"
@@ -5607,7 +5607,7 @@ msgstr "Kopier feil til utklippstavlen"
#: frappe/public/js/frappe/form/toolbar.js:507
msgid "Copy to Clipboard"
-msgstr ""
+msgstr "Kopier til utklippstavlen"
#: frappe/core/doctype/user/user.js:480
msgid "Copy token to clipboard"
@@ -5620,7 +5620,7 @@ msgstr "Opphavsrett"
#: frappe/custom/doctype/customize_form/customize_form.py:123
msgid "Core DocTypes cannot be customized."
-msgstr ""
+msgstr "DocType-er i kjernen kan ikke tilpasses."
#: frappe/desk/doctype/global_search_settings/global_search_settings.py:36
msgid "Core Modules {0} cannot be searched in Global Search."
@@ -5632,23 +5632,23 @@ msgstr "Riktig versjon:"
#: frappe/email/smtp.py:78
msgid "Could not connect to outgoing email server"
-msgstr ""
+msgstr "Kunne ikke koble til serveren for utgående e-post"
#: frappe/model/document.py:1101
msgid "Could not find {0}"
-msgstr ""
+msgstr "Kunne ikke finne {0}"
#: frappe/core/doctype/data_import/importer.py:933
msgid "Could not map column {0} to field {1}"
-msgstr ""
+msgstr "Kunne ikke tilordne kolonne {0} til felt {1}"
#: frappe/database/query.py:564
msgid "Could not parse field: {0}"
-msgstr ""
+msgstr "Kunne ikke analysere feltet: {0}"
#: frappe/desk/page/setup_wizard/setup_wizard.js:234
msgid "Could not start up:"
-msgstr ""
+msgstr "Kunne ikke starte opp:"
#: frappe/public/js/frappe/web_form/web_form.js:383
msgid "Couldn't save, please check the data you have entered"
@@ -5669,7 +5669,7 @@ msgstr "Antall"
#: frappe/public/js/frappe/widgets/widget_dialog.js:540
msgid "Count Customizations"
-msgstr ""
+msgstr "Egendefinering av teller"
#. Label of the section_break_5 (Section Break) field in DocType 'Workspace
#. Shortcut'
@@ -5686,7 +5686,7 @@ msgstr "Antall lenkede dokumenter"
#. Label of the counter (Int) field in DocType 'Document Naming Rule'
#: frappe/core/doctype/document_naming_rule/document_naming_rule.json
msgid "Counter"
-msgstr ""
+msgstr "Teller"
#. Label of the country (Link) field in DocType 'Address'
#. Label of the country (Link) field in DocType 'Address Template'
@@ -5709,7 +5709,7 @@ msgstr "Landskode er påkrevd"
#. Label of the country_name (Data) field in DocType 'Country'
#: frappe/geo/doctype/country/country.json
msgid "Country Name"
-msgstr ""
+msgstr "Landsnavn"
#. Label of the county (Data) field in DocType 'Address'
#: frappe/contacts/doctype/address/address.json
@@ -8164,7 +8164,7 @@ msgstr "Last ned flere vCard"
#: frappe/desk/page/setup_wizard/install_fixtures.py:46
msgid "Dr"
-msgstr "Debet"
+msgstr "Dr."
#: frappe/public/js/frappe/model/indicator.js:73
#: frappe/public/js/frappe/ui/filters/filter.js:538
@@ -9885,7 +9885,7 @@ msgstr "FavIcon"
#. Label of the fax (Data) field in DocType 'Address'
#: frappe/contacts/doctype/address/address.json
msgid "Fax"
-msgstr ""
+msgstr "Faks"
#: frappe/public/js/frappe/form/templates/form_sidebar.html:33
msgid "Feedback"
@@ -22719,7 +22719,7 @@ msgstr "Søk i en dokumenttype"
#: frappe/public/js/frappe/ui/toolbar/navbar.html:29
msgid "Search or type a command ({0})"
-msgstr "Søk eller skriv inn en kommando ({0})"
+msgstr "Søk eller skriv en kommando ({0})"
#: frappe/public/js/form_builder/components/SearchBox.vue:8
msgid "Search properties..."
diff --git a/frappe/locale/sr.po b/frappe/locale/sr.po
index 3bfb34e471..b5a0a259fb 100644
--- a/frappe/locale/sr.po
+++ b/frappe/locale/sr.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2025-09-14 09:32+0000\n"
-"PO-Revision-Date: 2025-09-15 18:13\n"
+"PO-Revision-Date: 2025-09-18 19:07\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Serbian (Cyrillic)\n"
"MIME-Version: 1.0\n"
@@ -5453,7 +5453,7 @@ msgstr "Контакт"
#: frappe/integrations/doctype/google_calendar/google_calendar.py:812
msgid "Contact / email not found. Did not add attendee for -
{0}"
-msgstr ""
+msgstr "Контакт / имејл није пронађен. Учесник није додат за -
{0}"
#. Label of the sb_01 (Section Break) field in DocType 'Contact'
#: frappe/contacts/doctype/contact/contact.json
@@ -13678,7 +13678,7 @@ msgstr "Примарно"
#: frappe/contacts/report/addresses_and_contacts/addresses_and_contacts.py:43
msgid "Is Primary Address"
-msgstr ""
+msgstr "Примарна адреса"
#. Label of the is_primary_contact (Check) field in DocType 'Contact'
#: frappe/contacts/doctype/contact/contact.json
@@ -26297,7 +26297,7 @@ msgstr "Овај месец"
#: frappe/core/doctype/file/file.py:394
msgid "This PDF cannot be uploaded as it contains unsafe content."
-msgstr ""
+msgstr "Овај PDF не може бити отпремљен јер садржи небезбедан садржај."
#: frappe/public/js/frappe/ui/filters/filter.js:670
msgid "This Quarter"
diff --git a/frappe/locale/sr_CS.po b/frappe/locale/sr_CS.po
index 105c78dbe7..c0eb5bedb6 100644
--- a/frappe/locale/sr_CS.po
+++ b/frappe/locale/sr_CS.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2025-09-14 09:32+0000\n"
-"PO-Revision-Date: 2025-09-15 18:13\n"
+"PO-Revision-Date: 2025-09-18 19:07\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Serbian (Latin)\n"
"MIME-Version: 1.0\n"
@@ -5454,7 +5454,7 @@ msgstr "Kontakt"
#: frappe/integrations/doctype/google_calendar/google_calendar.py:812
msgid "Contact / email not found. Did not add attendee for -
{0}"
-msgstr ""
+msgstr "Kontakt / imejl nije pronađen. Učesnik nije dodat za -
{0}"
#. Label of the sb_01 (Section Break) field in DocType 'Contact'
#: frappe/contacts/doctype/contact/contact.json
@@ -13679,7 +13679,7 @@ msgstr "Primarno"
#: frappe/contacts/report/addresses_and_contacts/addresses_and_contacts.py:43
msgid "Is Primary Address"
-msgstr ""
+msgstr "Primarna adresa"
#. Label of the is_primary_contact (Check) field in DocType 'Contact'
#: frappe/contacts/doctype/contact/contact.json
@@ -26298,7 +26298,7 @@ msgstr "Ovaj mesec"
#: frappe/core/doctype/file/file.py:394
msgid "This PDF cannot be uploaded as it contains unsafe content."
-msgstr ""
+msgstr "Ovaj PDF ne može biti otpremljen jer sadrži nebezbedan sadržaj."
#: frappe/public/js/frappe/ui/filters/filter.js:670
msgid "This Quarter"
diff --git a/frappe/locale/sv.po b/frappe/locale/sv.po
index 48d3c35591..161618f087 100644
--- a/frappe/locale/sv.po
+++ b/frappe/locale/sv.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2025-09-14 09:32+0000\n"
-"PO-Revision-Date: 2025-09-15 18:12\n"
+"PO-Revision-Date: 2025-09-18 19:07\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Swedish\n"
"MIME-Version: 1.0\n"
@@ -5453,7 +5453,7 @@ msgstr "Kontakt"
#: frappe/integrations/doctype/google_calendar/google_calendar.py:812
msgid "Contact / email not found. Did not add attendee for -
{0}"
-msgstr ""
+msgstr "Kontakt / e-post hittades inte. Har inte lagt till deltagare för -
{0}"
#. Label of the sb_01 (Section Break) field in DocType 'Contact'
#: frappe/contacts/doctype/contact/contact.json
@@ -13677,7 +13677,7 @@ msgstr "Är Primär"
#: frappe/contacts/report/addresses_and_contacts/addresses_and_contacts.py:43
msgid "Is Primary Address"
-msgstr ""
+msgstr "Är Primär Adress"
#. Label of the is_primary_contact (Check) field in DocType 'Contact'
#: frappe/contacts/doctype/contact/contact.json
@@ -14000,7 +14000,7 @@ msgstr "Håll koll på alla uppdatering flöde"
#. Description of a DocType
#: frappe/core/doctype/communication/communication.json
msgid "Keeps track of all communications"
-msgstr "Konversation Översikt"
+msgstr "Håller koll på all kommunikation"
#. Label of the defkey (Data) field in DocType 'DefaultValue'
#. Label of the key (Data) field in DocType 'Document Share Key'
@@ -26292,7 +26292,7 @@ msgstr "Denna Månad"
#: frappe/core/doctype/file/file.py:394
msgid "This PDF cannot be uploaded as it contains unsafe content."
-msgstr ""
+msgstr "Denna PDF kan inte laddas upp eftersom den innehåller osäkert innehåll."
#: frappe/public/js/frappe/ui/filters/filter.js:670
msgid "This Quarter"
diff --git a/frappe/public/js/frappe/form/print_utils.js b/frappe/public/js/frappe/form/print_utils.js
index 06a5843ae4..b0f117973d 100644
--- a/frappe/public/js/frappe/form/print_utils.js
+++ b/frappe/public/js/frappe/form/print_utils.js
@@ -1,4 +1,10 @@
-frappe.ui.get_print_settings = function (pdf, callback, letter_head, pick_columns) {
+frappe.ui.get_print_settings = function (
+ pdf,
+ callback,
+ letter_head,
+ pick_columns,
+ has_filters = false
+) {
var print_settings = locals[":Print Settings"]["Print Settings"];
var company = frappe.defaults.get_default("company");
@@ -48,6 +54,14 @@ frappe.ui.get_print_settings = function (pdf, callback, letter_head, pick_column
},
];
+ if (has_filters) {
+ columns.push({
+ label: __("Include filters"),
+ fieldtype: "Check",
+ fieldname: "include_filters",
+ });
+ }
+
if (pick_columns) {
columns.push(
{
diff --git a/frappe/public/js/frappe/form/save.js b/frappe/public/js/frappe/form/save.js
index fe412aa241..b28e277a86 100644
--- a/frappe/public/js/frappe/form/save.js
+++ b/frappe/public/js/frappe/form/save.js
@@ -17,7 +17,7 @@ frappe.ui.form.save = function (frm, action, callback, btn) {
var save = function () {
$(frm.wrapper).addClass("validated-form");
- if ((action !== "Save" || frm.is_dirty()) && check_mandatory()) {
+ if ((action !== "Save" || frm.is_dirty()) && frappe.ui.form.check_mandatory(frm)) {
_call({
method: "frappe.desk.form.save.savedocs",
args: { doc: frm.doc, action: action },
@@ -65,116 +65,6 @@ frappe.ui.form.save = function (frm, action, callback, btn) {
});
};
- var check_mandatory = function () {
- var has_errors = false;
- frm.scroll_set = false;
-
- if (frm.doc.docstatus == 2) return true; // don't check for cancel
-
- $.each(frappe.model.get_all_docs(frm.doc), function (i, doc) {
- var error_fields = [];
- var folded = false;
-
- $.each(frappe.meta.docfield_list[doc.doctype] || [], function (i, docfield) {
- if (docfield.fieldname) {
- const df = frappe.meta.get_docfield(doc.doctype, docfield.fieldname, doc.name);
-
- if (df.fieldtype === "Fold") {
- folded = frm.layout.folded;
- }
-
- if (
- is_docfield_mandatory(doc, df) &&
- !frappe.model.has_value(doc.doctype, doc.name, df.fieldname)
- ) {
- has_errors = true;
- error_fields[error_fields.length] = __(df.label, null, df.parent);
- // scroll to field
- if (!frm.scroll_set) {
- scroll_to(doc.parentfield || df.fieldname);
- }
-
- if (folded) {
- frm.layout.unfold();
- folded = false;
- }
- }
- }
- });
-
- if (frm.is_new() && frm.meta.autoname === "Prompt" && !frm.doc.__newname) {
- has_errors = true;
- error_fields = [__("Name"), ...error_fields];
- }
-
- if (error_fields.length) {
- let meta = frappe.get_meta(doc.doctype);
- let message;
- if (meta.istable) {
- const table_field = frappe.meta.docfield_map[doc.parenttype][doc.parentfield];
-
- const table_label = __(
- table_field.label || frappe.unscrub(table_field.fieldname)
- ).bold();
-
- message = __("Mandatory fields required in table {0}, Row {1}", [
- table_label,
- doc.idx,
- ]);
- } else {
- message = __("Mandatory fields required in {0}", [__(doc.doctype)]);
- }
- message = message + "
- " + error_fields.join("
- ") + "
";
- frappe.msgprint({
- message: message,
- indicator: "red",
- title: __("Missing Fields"),
- });
- frm.refresh();
- }
- });
-
- return !has_errors;
- };
-
- let is_docfield_mandatory = function (doc, df) {
- if (df.reqd) return true;
- if (!df.mandatory_depends_on || !doc) return;
-
- let out = null;
- let expression = df.mandatory_depends_on;
- let parent = frappe.get_meta(df.parent);
-
- if (typeof expression === "boolean") {
- out = expression;
- } else if (typeof expression === "function") {
- out = expression(doc);
- } else if (expression.substr(0, 5) == "eval:") {
- try {
- out = frappe.utils.eval(expression.substr(5), { doc, parent });
- if (parent && parent.istable && expression.includes("is_submittable")) {
- out = true;
- }
- } catch (e) {
- frappe.throw(__('Invalid "mandatory_depends_on" expression'));
- }
- } else {
- var value = doc[expression];
- if ($.isArray(value)) {
- out = !!value.length;
- } else {
- out = !!value;
- }
- }
-
- return out;
- };
-
- const scroll_to = (fieldname) => {
- frm.scroll_to_field(fieldname);
- frm.scroll_set = true;
- };
-
var _call = function (opts) {
// opts = {
// method: "some server method",
@@ -227,6 +117,116 @@ frappe.ui.form.save = function (frm, action, callback, btn) {
}
};
+frappe.ui.form.check_mandatory = function (frm) {
+ var has_errors = false;
+ frm.scroll_set = false;
+
+ if (frm.doc.docstatus == 2) return true; // don't check for cancel
+
+ $.each(frappe.model.get_all_docs(frm.doc), function (i, doc) {
+ var error_fields = [];
+ var folded = false;
+
+ $.each(frappe.meta.docfield_list[doc.doctype] || [], function (i, docfield) {
+ if (docfield.fieldname) {
+ const df = frappe.meta.get_docfield(doc.doctype, docfield.fieldname, doc.name);
+
+ if (df.fieldtype === "Fold") {
+ folded = frm.layout.folded;
+ }
+
+ if (
+ is_docfield_mandatory(doc, df) &&
+ !frappe.model.has_value(doc.doctype, doc.name, df.fieldname)
+ ) {
+ has_errors = true;
+ error_fields[error_fields.length] = __(df.label, null, df.parent);
+ // scroll to field
+ if (!frm.scroll_set) {
+ scroll_to(doc.parentfield || df.fieldname);
+ }
+
+ if (folded) {
+ frm.layout.unfold();
+ folded = false;
+ }
+ }
+ }
+ });
+
+ if (frm.is_new() && frm.meta.autoname === "Prompt" && !frm.doc.__newname) {
+ has_errors = true;
+ error_fields = [__("Name"), ...error_fields];
+ }
+
+ if (error_fields.length) {
+ let meta = frappe.get_meta(doc.doctype);
+ let message;
+ if (meta.istable) {
+ const table_field = frappe.meta.docfield_map[doc.parenttype][doc.parentfield];
+
+ const table_label = __(
+ table_field.label || frappe.unscrub(table_field.fieldname)
+ ).bold();
+
+ message = __("Mandatory fields required in table {0}, Row {1}", [
+ table_label,
+ doc.idx,
+ ]);
+ } else {
+ message = __("Mandatory fields required in {0}", [__(doc.doctype)]);
+ }
+ message = message + "
- " + error_fields.join("
- ") + "
";
+ frappe.msgprint({
+ message: message,
+ indicator: "red",
+ title: __("Missing Fields"),
+ });
+ frm.refresh();
+ }
+ });
+
+ return !has_errors;
+
+ function is_docfield_mandatory(doc, df) {
+ if (df.reqd) return true;
+ if (!df.mandatory_depends_on || !doc) return;
+
+ let out = null;
+ let expression = df.mandatory_depends_on;
+ let parent = frappe.get_meta(df.parent);
+
+ if (typeof expression === "boolean") {
+ out = expression;
+ } else if (typeof expression === "function") {
+ out = expression(doc);
+ } else if (expression.substr(0, 5) == "eval:") {
+ try {
+ out = frappe.utils.eval(expression.substr(5), { doc, parent });
+ if (parent && parent.istable && expression.includes("is_submittable")) {
+ out = true;
+ }
+ } catch (e) {
+ frappe.throw(__('Invalid "mandatory_depends_on" expression'));
+ }
+ } else {
+ var value = doc[expression];
+ if ($.isArray(value)) {
+ out = !!value.length;
+ } else {
+ out = !!value;
+ }
+ }
+
+ return out;
+ }
+
+ function scroll_to(fieldname) {
+ frm.scroll_to_field(fieldname);
+ frm.scroll_set = true;
+ }
+};
+
frappe.ui.form.remove_old_form_route = () => {
let current_route = frappe.get_route().join("/");
frappe.route_history = frappe.route_history.filter(
diff --git a/frappe/public/js/frappe/form/workflow.js b/frappe/public/js/frappe/form/workflow.js
index e3532fddc8..a4eaafb873 100644
--- a/frappe/public/js/frappe/form/workflow.js
+++ b/frappe/public/js/frappe/form/workflow.js
@@ -98,6 +98,7 @@ frappe.ui.form.States = class FormStates {
frappe.workflow.get_transitions(this.frm.doc).then((transitions) => {
this.frm.page.clear_actions_menu();
+ const frm = this.frm;
transitions.forEach((d) => {
if (frappe.user_roles.includes(d.allowed) && has_approval_access(d)) {
added = true;
@@ -105,6 +106,7 @@ frappe.ui.form.States = class FormStates {
// set the workflow_action for use in form scripts
frappe.dom.freeze();
me.frm.selected_workflow_action = d.action;
+ if (!frappe.ui.form.check_mandatory(frm)) return frappe.dom.unfreeze();
me.frm.script_manager.trigger("before_workflow_action").then(() => {
frappe
.xcall("frappe.model.workflow.apply_workflow", {
diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js
index 5a5b75bdeb..37e0980cfd 100644
--- a/frappe/public/js/frappe/utils/utils.js
+++ b/frappe/public/js/frappe/utils/utils.js
@@ -1144,8 +1144,6 @@ Object.assign(frappe.utils, {
seconds_to_duration(seconds, duration_options) {
const floor = seconds > 0 ? Math.floor : Math.ceil;
- const round_base_60 = (seconds) => floor(seconds / 60 + (seconds > 0 ? 0.5 : -0.5));
-
const total_duration = {
days: floor(seconds / 86400), // 60 * 60 * 24
hours: floor((seconds % 86400) / 3600),
@@ -1159,7 +1157,7 @@ Object.assign(frappe.utils, {
}
if (duration_options && duration_options.hide_seconds) {
- total_duration.minutes += round_base_60(total_duration.seconds);
+ total_duration.minutes += Math.round(total_duration.seconds / 60);
total_duration.seconds = 0;
}
diff --git a/frappe/public/js/frappe/views/reports/print_grid.html b/frappe/public/js/frappe/views/reports/print_grid.html
index 7af27145a6..8e0f31280b 100644
--- a/frappe/public/js/frappe/views/reports/print_grid.html
+++ b/frappe/public/js/frappe/views/reports/print_grid.html
@@ -4,8 +4,8 @@
{% endif %}
{% if subtitle %}
-{{ subtitle }}
-
+ {{ subtitle }}
+
{% endif %}
diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js
index e0a9270b54..75859d0dab 100644
--- a/frappe/public/js/frappe/views/reports/query_report.js
+++ b/frappe/public/js/frappe/views/reports/query_report.js
@@ -730,6 +730,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
is_tree: this.report_settings.tree,
parent_field: this.report_settings.parent_field,
are_default_filters: are_default_filters,
+ js_filters: frappe.query_reports[this.report_name]?.filters,
},
callback: resolve,
always: () => this.page.btn_secondary.prop("disabled", false),
@@ -1522,7 +1523,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
frappe.render_grid({
template: print_settings.columns ? "print_grid" : custom_format,
title: __(this.report_name),
- subtitle: filters_html,
+ subtitle: print_settings?.include_filters ? filters_html : null,
print_settings: print_settings,
landscape: landscape,
filters: this.get_filter_values(),
@@ -1552,7 +1553,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
const template = print_settings.columns || !custom_format ? "print_grid" : custom_format;
const content = frappe.render_template(template, {
title: __(this.report_name),
- subtitle: filters_html,
+ subtitle: print_settings?.include_filters ? filters_html : null,
filters: applied_filters,
data: data,
original_data: this.data,
@@ -1818,7 +1819,8 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
false,
(print_settings) => this.print_report(print_settings),
this.report_doc.letter_head,
- this.get_visible_columns()
+ this.get_visible_columns(),
+ true
);
this.add_portrait_warning(dialog);
},
@@ -1832,7 +1834,8 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
false,
(print_settings) => this.pdf_report(print_settings),
this.report_doc.letter_head,
- this.get_visible_columns()
+ this.get_visible_columns(),
+ true
);
this.add_portrait_warning(dialog);
diff --git a/frappe/pulse/__init__.py b/frappe/pulse/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/pulse/app_heartbeat_event.py b/frappe/pulse/app_heartbeat_event.py
new file mode 100644
index 0000000000..9499ab85a1
--- /dev/null
+++ b/frappe/pulse/app_heartbeat_event.py
@@ -0,0 +1,63 @@
+import frappe
+from frappe.modules import get_doctype_module
+from frappe.pulse.utils import get_app_version, get_frappe_version
+from frappe.utils.caching import site_cache
+
+from .client import capture, is_enabled
+
+
+def capture_app_heartbeat(req_params):
+ if not should_capture():
+ return
+
+ method, doctype = get_method_and_doctype(req_params)
+ if not method and not doctype:
+ return
+
+ app_name = get_app_name(method, doctype)
+ if app_name and app_name != "frappe":
+ capture(
+ event_name="app_heartbeat",
+ site=frappe.local.site,
+ app=app_name,
+ properties={
+ "app_version": get_app_version(app_name),
+ "frappe_version": get_frappe_version(),
+ },
+ interval="6h",
+ )
+
+
+def should_capture():
+ if not is_enabled() or frappe.session.user in frappe.STANDARD_USERS:
+ return False
+
+ status_code = frappe.response.http_status_code or 0
+ if status_code and not (200 <= status_code < 300):
+ return False
+
+ return True
+
+
+def get_method_and_doctype(req_params):
+ method = req_params.get("method") or frappe.form_dict.get("method")
+ doctype = req_params.get("doctype") or frappe.form_dict.get("doctype")
+ return method, doctype
+
+
+def get_app_name(method, doctype):
+ app_name = None
+ if method and "." in method and not method.startswith("frappe."):
+ app_name = method.split(".", 1)[0]
+
+ if not app_name and doctype:
+ module = get_doctype_module(doctype)
+ app_name = app_module_map().get(module)
+
+ return app_name
+
+
+@site_cache()
+def app_module_map():
+ defs = frappe.get_all("Module Def", fields=["name", "app_name"])
+ return {d.name: d.app_name for d in defs}
diff --git a/frappe/pulse/client.py b/frappe/pulse/client.py
new file mode 100644
index 0000000000..7ffe3a6e28
--- /dev/null
+++ b/frappe/pulse/client.py
@@ -0,0 +1,136 @@
+import time
+from contextlib import suppress
+
+from orjson import JSONDecodeError
+
+import frappe
+from frappe.pulse.utils import anonymize_user, ensure_http, parse_interval, utc_iso
+from frappe.utils import get_request_session
+from frappe.utils.caching import site_cache
+from frappe.utils.frappecloud import on_frappecloud
+
+
+@frappe.whitelist()
+@site_cache()
+def is_enabled() -> bool:
+ return (
+ not frappe.conf.get("developer_mode", 0)
+ and not frappe.conf.get("pulse_disabled", 0)
+ and frappe.conf.get("pulse_api_key")
+ and on_frappecloud()
+ and frappe.get_system_settings("enable_telemetry")
+ )
+
+
+def capture(event_name, site=None, app=None, user=None, properties=None, interval=None):
+ if not is_enabled():
+ return
+
+ try:
+ event_key = f"{event_name}:{site}:{app}:{user}"
+ if _is_ratelimited(event_key, interval):
+ return
+
+ _queue_event(
+ {
+ "event_name": event_name,
+ "captured_at": utc_iso(),
+ "app": app,
+ "user": anonymize_user(user),
+ "site": site or frappe.local.site,
+ "properties": properties,
+ }
+ )
+ _update_ratelimit(event_key, interval)
+ except Exception as e:
+ frappe.logger().error(f"Pulse event capture failed: {e!s}")
+
+
+def _is_ratelimited(event_key, interval):
+ if not interval:
+ return False
+
+ interval_seconds = parse_interval(interval)
+ last_sent_key = f"pulse-client:last_sent:{event_key}"
+ last_sent = frappe.cache.get_value(last_sent_key)
+
+ if last_sent and time.monotonic() - float(last_sent) < interval_seconds:
+ return True
+
+ return False
+
+
+def _update_ratelimit(event_key, interval):
+ if not interval:
+ return
+ last_sent_key = f"pulse-client:last_sent:{event_key}"
+ frappe.cache.set_value(last_sent_key, time.monotonic(), expires_in_sec=86400) # 24h TTL
+
+
+def _queue_event(event):
+ frappe.cache.lpush("pulse-client:events", frappe.as_json(event))
+ frappe.cache.ltrim("pulse-client:events", 0, 4999)
+
+
+def queue_length():
+ return frappe.cache.llen("pulse-client:events")
+
+
+def send_queued_events():
+ batch_size = 100
+ max_batches = 10
+ for _ in range(max_batches):
+ events = get_next_batch(batch_size)
+ if not events:
+ break
+ try:
+ if not post(events):
+ frappe.logger().error("Pulse sending events failed: non-2xx response")
+ except Exception as e:
+ frappe.logger().error(f"Pulse sending events failed: {e!s}")
+
+
+def get_next_batch(batch_size=100):
+ """Get batch of events from the queue"""
+ events = []
+ for _ in range(batch_size):
+ event_json = frappe.cache.rpop("pulse-client:events")
+ if not event_json:
+ break
+ event_json = event_json.decode()
+ with suppress(JSONDecodeError):
+ data = frappe.parse_json(event_json)
+ events.append(data)
+ return events
+
+
+def post(events):
+ # TODO: implement retry logic
+ session = _create_session()
+ url = _get_ingest_url()
+ data = frappe.as_json({"events": events})
+ resp = session.post(url, data=data, timeout=15)
+ return 200 <= resp.status_code < 300
+
+
+def _create_session():
+ api_key = frappe.conf.get("pulse_api_key")
+ session = get_request_session()
+ session.headers.update(
+ {
+ "Content-Type": "application/json",
+ "X-Pulse-API-Key": api_key,
+ }
+ )
+ return session
+
+
+def _get_ingest_url():
+ host = frappe.conf.get("pulse_host") or "https://pulse.m.frappe.cloud"
+ host = ensure_http(host)
+ host = host.rstrip("/")
+
+ endpoint = frappe.conf.get("pulse_ingest_endpoint") or "/api/method/pulse.api.bulk_ingest"
+ endpoint = endpoint.lstrip("/")
+
+ return f"{host}/{endpoint}"
diff --git a/frappe/pulse/utils.py b/frappe/pulse/utils.py
new file mode 100644
index 0000000000..fd68a93a01
--- /dev/null
+++ b/frappe/pulse/utils.py
@@ -0,0 +1,97 @@
+import hashlib
+from datetime import datetime, timezone
+
+import frappe
+
+
+def anonymize_user(user):
+ """
+ Create consistent anonymous ID from user email.
+ Same email always produces same anonymous ID.
+ """
+ if not user or user in frappe.STANDARD_USERS:
+ return user
+
+ # Use site-specific salt for additional security
+ site_salt = frappe.local.site or "default"
+
+ # Create deterministic hash
+ hash_input = f"{user}:{site_salt}".encode()
+ user_hash = hashlib.sha256(hash_input).hexdigest()
+
+ # Return first 12 characters for readability
+ return f"anon_{user_hash[:12]}"
+
+
+def parse_interval(interval):
+ """
+ Parse interval string or integer into seconds.
+
+ Args:
+ interval: Can be:
+ - Integer: seconds (e.g., 3600)
+ - String: number + unit (e.g., "1h", "30m", "7d")
+
+ Returns:
+ int: Total seconds
+
+ Examples:
+ parse_interval(3600) -> 3600
+ parse_interval("1h") -> 3600
+ parse_interval("30m") -> 1800
+ parse_interval("7d") -> 604800
+ """
+ if interval is None:
+ return None
+
+ # If already an integer, return as-is (assuming seconds)
+ if isinstance(interval, int):
+ return interval
+
+ # Parse string format
+ interval = str(interval).strip().lower()
+
+ # Extract number and unit
+ if interval[-1].isdigit():
+ # No unit specified, assume seconds
+ return int(interval)
+
+ unit = interval[-1]
+ try:
+ number = int(interval[:-1])
+ except ValueError:
+ raise ValueError(f"Invalid interval format: {interval}")
+
+ # Convert to seconds
+ multipliers = {
+ "s": 1, # seconds
+ "m": 60, # minutes
+ "h": 3600, # hours
+ "d": 86400, # days
+ "w": 604800, # weeks
+ "y": 31536000, # years
+ }
+
+ if unit not in multipliers:
+ raise ValueError(f"Invalid time unit '{unit}'. Use: s, m, h, d, w, y")
+
+ return number * multipliers[unit]
+
+
+def get_frappe_version() -> str:
+ return getattr(frappe, "__version__", "unknown")
+
+
+def utc_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def get_app_version(app_name: str) -> str:
+ try:
+ return frappe.get_attr(app_name + ".__version__")
+ except Exception:
+ return "0.0.1"
+
+
+def ensure_http(url: str) -> str:
+ return url if url.startswith(("http://", "https://")) else "https://" + url
diff --git a/frappe/tests/test_api.py b/frappe/tests/test_api.py
index aa40817bc4..4518fdc52d 100644
--- a/frappe/tests/test_api.py
+++ b/frappe/tests/test_api.py
@@ -461,13 +461,15 @@ class TestResponse(FrappeAPITestCase):
def test_login_redirects(self):
expected_redirects = {
- "/app/user": "/app/user",
- "/app/user?enabled=1": "/app/user?enabled=1",
- "http://example.com": "/app", # No external redirect
- "https://google.com": "/app",
- "http://localhost:8000": "/app",
+ "/app/user": "http://localhost/app/user",
+ "/app/user?enabled=1": "http://localhost/app/user?enabled=1",
+ "http://example.com": "http://localhost/app", # No external redirect
+ "https://google.com": "http://localhost/app",
+ "http://localhost:8000": "http://localhost/app",
"http://localhost/app": "http://localhost/app",
+ "////example.com": "http://localhost//example.com", # malicious redirect attempt
}
+
for redirect, expected_redirect in expected_redirects.items():
response = self.get(f"/login?{urlencode({'redirect-to':redirect})}", {"sid": self.sid})
self.assertEqual(response.location, expected_redirect)
diff --git a/frappe/utils/response.py b/frappe/utils/response.py
index 4265ac64e9..cf99e5ba02 100644
--- a/frappe/utils/response.py
+++ b/frappe/utils/response.py
@@ -19,7 +19,6 @@ import werkzeug.utils
from werkzeug.exceptions import Forbidden, NotFound
from werkzeug.local import LocalProxy
from werkzeug.wrappers import Response
-from werkzeug.wsgi import wrap_file
import frappe
import frappe.model.document
@@ -306,26 +305,24 @@ def send_private_file(path: str) -> Response:
response = Response()
response.headers["X-Accel-Redirect"] = quote(frappe.utils.encode(path))
response.headers["Cache-Control"] = "private,max-age=3600,stale-while-revalidate=86400"
+ response.headers["Accept-Ranges"] = "bytes"
else:
filepath = frappe.utils.get_site_path(path)
- try:
- f = open(filepath, "rb")
- except OSError:
+ if not os.path.exists(filepath):
raise NotFound
- response = Response(wrap_file(frappe.local.request.environ, f), direct_passthrough=True)
+ extension = os.path.splitext(path)[1]
+ blacklist = [".svg", ".html", ".htm", ".xml"]
+ as_attachment = extension.lower() in blacklist
- # no need for content disposition and force download. let browser handle its opening.
- # Except for those that can be injected with scripts.
-
- extension = os.path.splitext(path)[1]
- blacklist = [".svg", ".html", ".htm", ".xml"]
-
- if extension.lower() in blacklist:
- response.headers.add("Content-Disposition", "attachment", filename=filename)
-
- response.mimetype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
+ response = werkzeug.utils.send_file(
+ filepath,
+ environ=frappe.local.request.environ,
+ conditional=True,
+ as_attachment=as_attachment,
+ download_name=filename if as_attachment else None,
+ )
return response
diff --git a/frappe/www/login.py b/frappe/www/login.py
index c9a5e63816..cbd71bb77f 100644
--- a/frappe/www/login.py
+++ b/frappe/www/login.py
@@ -2,7 +2,7 @@
# License: MIT. See LICENSE
-from urllib.parse import urlparse
+from urllib.parse import urljoin, urlparse
import frappe
import frappe.utils
@@ -202,17 +202,21 @@ def sanitize_redirect(redirect: str | None) -> str | None:
Allowed redirects:
- Same host e.g. https://frappe.localhost/path
- - Just path e.g. /app
+ - Just path e.g. /app gets converted to https://frappe.localhost/app
"""
if not redirect:
return redirect
parsed_redirect = urlparse(redirect)
- if not parsed_redirect.netloc:
- return redirect
parsed_request_host = urlparse(frappe.local.request.url)
- if parsed_request_host.netloc == parsed_redirect.netloc:
- return redirect
+ output_parsed_url = parsed_redirect._replace(
+ netloc=parsed_request_host.netloc, scheme=parsed_request_host.scheme
+ )
+ if parsed_redirect.netloc:
+ if parsed_request_host.netloc != parsed_redirect.netloc:
+ output_parsed_url = output_parsed_url._replace(path="/app")
+ else:
+ output_parsed_url = output_parsed_url._replace(path=parsed_redirect.path)
- return None
+ return output_parsed_url.geturl()