Merge branch 'develop' into 32489-role-perm-based-masking

This commit is contained in:
mergify[bot] 2025-09-19 09:32:01 +00:00 committed by GitHub
commit af27ab36e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 674 additions and 298 deletions

View file

@ -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", () => {

View file

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

View file

@ -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 = $(`<table class="table table-bordered" style="cursor:pointer; margin:0px;">
let table = $(`<table class="table table-bordered" style="cursor:${
frm.has_perm("write") ? "pointer" : "default"
}; margin:0px;">
<thead>
<tr>
<th style="width: 20%">${__("Filter")}</th>
@ -212,7 +217,10 @@ frappe.ui.form.on("Number Card", {
</thead>
<tbody></tbody>
</table>`).appendTo(wrapper);
$(`<p class="text-muted small">${__("Click table to edit")}</p>`).appendTo(wrapper);
if (frm.has_perm("write")) {
$(`<p class="text-muted small">${__("Click table to edit")}</p>`).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 =
$(`<table class="table table-bordered" style="cursor:pointer; margin:0px;">
frm.dynamic_filter_table = $(`<table class="table table-bordered" style="cursor:${
frm.has_perm("write") ? "pointer" : "default"
}; margin:0px;">
<thead>
<tr>
<th style="width: 20%">${__("Filter")}</th>
@ -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", "");
}
},
});

View file

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

View file

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

View file

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

View file

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

View file

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

1 language_code language_name enabled
53 mr मराठी 0
54 ms Melayu 0
55 my မြန်မာ 0
56 nb Norsk Bokmål 1
57 nl Nederlands 0
58 no Norsk 0
59 pl Polski 0

View file

@ -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 * * * *": [

View file

@ -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 -<br>{0}"
msgstr ""
msgstr "Kontakt/e-pošta nije pronađena. Nije dodan učesnik za -<br>{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"

View file

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

View file

@ -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&#39;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

View file

@ -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 -<br>{0}"
msgstr ""
msgstr "Kontakt/e-pošta nije pronađena. Nije dodan sudionik za -<br>{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"

View file

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

View file

@ -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 -<br>{0}"
msgstr ""
msgstr "Контакт / имејл није пронађен. Учесник није додат за -<br>{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"

View file

@ -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 -<br>{0}"
msgstr ""
msgstr "Kontakt / imejl nije pronađen. Učesnik nije dodat za -<br>{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"

View file

@ -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 -<br>{0}"
msgstr ""
msgstr "Kontakt / e-post hittades inte. Har inte lagt till deltagare för -<br>{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"

View file

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

View file

@ -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 + "<br><br><ul><li>" + error_fields.join("</li><li>") + "</ul>";
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 + "<br><br><ul><li>" + error_fields.join("</li><li>") + "</ul>";
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(

View file

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

View file

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

View file

@ -4,8 +4,8 @@
<hr>
{% endif %}
{% if subtitle %}
{{ subtitle }}
<hr>
{{ subtitle }}
<hr>
{% endif %}
<table class="table table-bordered">
<!-- heading -->

View file

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

0
frappe/pulse/__init__.py Normal file
View file

View file

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

136
frappe/pulse/client.py Normal file
View file

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

97
frappe/pulse/utils.py Normal file
View file

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

View file

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

View file

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

View file

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