Merge branch 'develop' into 32489-role-perm-based-masking
This commit is contained in:
commit
af27ab36e4
30 changed files with 674 additions and 298 deletions
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", "");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
|
@ -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 * * * *": [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
<hr>
|
||||
{% endif %}
|
||||
{% if subtitle %}
|
||||
{{ subtitle }}
|
||||
<hr>
|
||||
{{ subtitle }}
|
||||
<hr>
|
||||
{% endif %}
|
||||
<table class="table table-bordered">
|
||||
<!-- heading -->
|
||||
|
|
|
|||
|
|
@ -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
0
frappe/pulse/__init__.py
Normal file
63
frappe/pulse/app_heartbeat_event.py
Normal file
63
frappe/pulse/app_heartbeat_event.py
Normal 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
136
frappe/pulse/client.py
Normal 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
97
frappe/pulse/utils.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue