Merge branch 'develop' into fix-dashboard

This commit is contained in:
Rucha Mahabal 2023-04-03 15:14:55 +05:30 committed by GitHub
commit 1655eaca1e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 468 additions and 161 deletions

View file

@ -98,15 +98,17 @@ context("Kanban Board", () => {
});
it("Checks if Kanban Board edits are blocked for non-System Manager and non-owner of the Board", () => {
// create admin kanban board
cy.call("frappe.tests.ui_test_helpers.create_todo", { description: "Frappe User ToDo" });
cy.switch_to_user("Administrator");
cy.call("frappe.tests.ui_test_helpers.create_admin_kanban");
// remove sys manager
cy.remove_role("frappe@example.com", "System Manager");
cy.switch_to_user("frappe@example.com");
const noSystemManager = "nosysmanager@example.com";
cy.call("frappe.tests.ui_test_helpers.create_test_user", {
username: noSystemManager,
});
cy.remove_role(noSystemManager, "System Manager");
cy.call("frappe.tests.ui_test_helpers.create_todo", { description: "Frappe User ToDo" });
cy.call("frappe.tests.ui_test_helpers.create_admin_kanban");
cy.switch_to_user(noSystemManager);
cy.visit("/app/todo/view/kanban/Admin Kanban");
@ -122,7 +124,8 @@ context("Kanban Board", () => {
// Column actions should be hidden (dropdown for 'Archive' and indicators)
cy.get(".kanban .column-options").should("have.length", 0);
cy.add_role("frappe@example.com", "System Manager");
cy.switch_to_user("Administrator");
cy.call("frappe.client.delete", { doctype: "User", name: noSystemManager });
});
after(() => {

View file

@ -1274,7 +1274,7 @@ def reload_doc(
return frappe.modules.reload_doc(module, dt, dn, force=force, reset_permissions=reset_permissions)
@whitelist()
@whitelist(methods=["POST", "PUT"])
def rename_doc(
doctype: str,
old: str,

View file

@ -24,7 +24,11 @@ EXTRA_ARGS_CTX = {"ignore_unknown_options": True, "allow_extra_args": True}
@click.option("--app", help="Build assets for app")
@click.option("--apps", help="Build assets for specific apps")
@click.option(
"--hard-link", is_flag=True, default=False, help="Copy the files instead of symlinking"
"--hard-link",
is_flag=True,
default=False,
help="Copy the files instead of symlinking",
envvar="FRAPPE_HARD_LINK_ASSETS",
)
@click.option(
"--make-copy",
@ -908,7 +912,7 @@ def run_ui_tests(
os.chdir(app_base_path)
node_bin = subprocess.getoutput("yarn bin")
node_bin = subprocess.getoutput("(cd ../frappe && yarn bin)")
cypress_path = f"{node_bin}/cypress"
drag_drop_plugin_path = f"{node_bin}/../@4tw/cypress-drag-drop"
real_events_plugin_path = f"{node_bin}/../cypress-real-events"
@ -935,7 +939,7 @@ def run_ui_tests(
"@cypress/code-coverage@^3",
]
)
frappe.commands.popen(f"yarn add {packages} --no-lockfile")
frappe.commands.popen(f"(cd ../frappe && yarn add {packages} --no-lockfile)")
# run for headless mode
run_or_open = "run --browser chrome --record" if headless else "open"

View file

@ -254,19 +254,23 @@ def address_query(doctype, txt, searchfield, start, page_len, filters):
"""select
`tabAddress`.name, `tabAddress`.city, `tabAddress`.country
from
`tabAddress`, `tabDynamic Link`
`tabAddress`
join `tabDynamic Link`
on (`tabDynamic Link`.parent = `tabAddress`.name and `tabDynamic Link`.parenttype = 'Address')
where
`tabDynamic Link`.parent = `tabAddress`.name and
`tabDynamic Link`.parenttype = 'Address' and
`tabDynamic Link`.link_doctype = %(link_doctype)s and
`tabDynamic Link`.link_name = %(link_name)s and
ifnull(`tabAddress`.disabled, 0) = 0 and
({search_condition})
{mcond} {condition}
order by
if(locate(%(_txt)s, `tabAddress`.name), locate(%(_txt)s, `tabAddress`.name), 99999),
case
when locate(%(_txt)s, `tabAddress`.name) != 0
then locate(%(_txt)s, `tabAddress`.name)
else 99999
end,
`tabAddress`.idx desc, `tabAddress`.name
limit %(start)s, %(page_len)s """.format(
limit %(page_len)s offset %(start)s""".format(
mcond=get_match_cond(doctype),
search_condition=search_condition,
condition=condition or "",

View file

@ -1,7 +1,9 @@
# Copyright (c) 2015, Frappe Technologies and Contributors
# License: MIT. See LICENSE
from functools import partial
import frappe
from frappe.contacts.doctype.address.address import get_address_display
from frappe.contacts.doctype.address.address import address_query, get_address_display
from frappe.tests.utils import FrappeTestCase
@ -28,3 +30,29 @@ class TestAddress(FrappeTestCase):
address = frappe.get_list("Address")[0].name
display = get_address_display(frappe.get_doc("Address", address).as_dict())
self.assertTrue(display)
def test_address_query(self):
def query(doctype="Address", txt="", searchfield="name", start=0, page_len=20, filters=None):
if filters is None:
filters = {"link_doctype": "User", "link_name": "Administrator"}
return address_query(doctype, txt, searchfield, start, page_len, filters)
frappe.get_doc(
{
"address_type": "Billing",
"address_line1": "1",
"city": "Mumbai",
"state": "Maharashtra",
"country": "India",
"doctype": "Address",
"links": [
{
"link_doctype": "User",
"link_name": "Administrator",
}
],
}
).insert()
self.assertGreaterEqual(len(query(txt="Admin")), 1)
self.assertEqual(len(query(txt="what_zyx")), 0)

View file

@ -165,7 +165,7 @@ def _make(
if not comm.get_outgoing_email_account():
frappe.throw(
_(
"Unable to send mail because of a missing email account. Please setup default Email Account from Setup > Email > Email Account"
"Unable to send mail because of a missing email account. Please setup default Email Account from Settings > Email Account"
),
exc=frappe.OutgoingEmailError,
)

View file

@ -70,7 +70,7 @@ class CommunicationEmailMixin:
if include_sender:
cc.append(self.sender_mailid)
if is_inbound_mail_communcation:
if (doc_owner := self.get_owner()) not in frappe.STANDARD_USERS:
if (doc_owner := self.get_owner()) and (doc_owner not in frappe.STANDARD_USERS):
cc.append(doc_owner)
cc = set(cc) - {self.sender_mailid}
cc.update(self.get_assignees())
@ -216,7 +216,11 @@ class CommunicationEmailMixin:
"reference_name": self.reference_name,
"reference_type": self.reference_doctype,
}
return ToDo.get_owners(filters)
if self.reference_doctype and self.reference_name:
return ToDo.get_owners(filters)
else:
return []
@staticmethod
def filter_thread_notification_disbled_users(emails):

View file

@ -4,7 +4,7 @@
import frappe
import frappe.share
from frappe.automation.doctype.auto_repeat.test_auto_repeat import create_submittable_doctype
from frappe.tests.utils import FrappeTestCase
from frappe.tests.utils import FrappeTestCase, change_settings
test_dependencies = ["User"]
@ -139,3 +139,61 @@ class TestDocShare(FrappeTestCase):
test_doc.reload()
self.assertTrue(test_doc.has_permission("read"))
@change_settings("System Settings", {"disable_document_sharing": 1})
def test_share_disabled_add(self):
"Test if user loses share access on disabling share globally."
frappe.share.add("Event", self.event.name, self.user, share=1) # Share as admin
frappe.set_user(self.user)
# User does not have share access although given to them
self.assertFalse(self.event.has_permission("share"))
self.assertRaises(
frappe.PermissionError, frappe.share.add, "Event", self.event.name, "test1@example.com"
)
@change_settings("System Settings", {"disable_document_sharing": 1})
def test_share_disabled_add_with_ignore_permissions(self):
frappe.share.add("Event", self.event.name, self.user, share=1)
frappe.set_user(self.user)
# User does not have share access although given to them
self.assertFalse(self.event.has_permission("share"))
# Test if behaviour is consistent for developer overrides
frappe.share.add_docshare(
"Event", self.event.name, "test1@example.com", flags={"ignore_share_permission": True}
)
@change_settings("System Settings", {"disable_document_sharing": 1})
def test_share_disabled_set_permission(self):
frappe.share.add("Event", self.event.name, self.user, share=1)
frappe.set_user(self.user)
# User does not have share access although given to them
self.assertFalse(self.event.has_permission("share"))
self.assertRaises(
frappe.PermissionError,
frappe.share.set_permission,
"Event",
self.event.name,
"test1@example.com",
"read",
)
@change_settings("System Settings", {"disable_document_sharing": 1})
def test_share_disabled_assign_to(self):
"""
Assigning a document to a user without access must not share the document,
if sharing disabled.
"""
from frappe.desk.form.assign_to import add
frappe.share.add("Event", self.event.name, self.user, share=1)
frappe.set_user(self.user)
self.assertRaises(
frappe.ValidationError,
add,
{"doctype": "Event", "name": self.event.name, "assign_to": ["test1@example.com"]},
)

View file

@ -169,7 +169,6 @@ class ServerScript(Document):
return items
@frappe.whitelist()
def setup_scheduler_events(script_name, frequency):
"""Creates or Updates Scheduled Job Type documents based on the specified script name and frequency

View file

@ -13,6 +13,7 @@
"time_zone",
"enable_onboarding",
"setup_complete",
"disable_document_sharing",
"date_and_number_format",
"date_format",
"time_format",
@ -528,12 +529,18 @@
"fieldtype": "Select",
"label": "Rounding Method",
"options": "Banker's Rounding (legacy)\nBanker's Rounding\nCommercial Rounding"
},
{
"default": "0",
"fieldname": "disable_document_sharing",
"fieldtype": "Check",
"label": "Disable Document Sharing"
}
],
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
"modified": "2023-03-10 12:23:45.248125",
"modified": "2023-03-14 11:30:56.465653",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",
@ -552,4 +559,4 @@
"sort_order": "ASC",
"states": [],
"track_changes": 1
}
}

View file

@ -219,7 +219,10 @@ frappe.ui.form.on("User", {
});
}
if (frappe.session.user == doc.name || frappe.user.has_role("System Manager")) {
if (
cint(frappe.boot.sysdefaults.enable_two_factor_auth) &&
(frappe.session.user == doc.name || frappe.user.has_role("System Manager"))
) {
frm.add_custom_button(
__("Reset OTP Secret"),
function () {

View file

@ -200,7 +200,7 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
return db_size[0].get("database_size")
def log_query(self, query, values, debug, explain):
self.last_query = query = self._cursor._last_executed
self.last_query = query = self._cursor._executed
self._log_query(query, debug, explain)
return self.last_query

View file

@ -16,38 +16,27 @@ frappe.ui.form.on("Bulk Update", {
if (!frm.doc.update_value) {
frappe.throw(__('Field "value" is mandatory. Please specify value to be updated'));
} else {
frappe
.call({
method: "frappe.desk.doctype.bulk_update.bulk_update.update",
args: {
doctype: frm.doc.document_type,
field: frm.doc.field,
value: frm.doc.update_value,
condition: frm.doc.condition,
limit: frm.doc.limit,
},
})
.then((r) => {
let failed = r.message;
if (!failed) failed = [];
frm.call("bulk_update").then((r) => {
let failed = r.message;
if (!failed) failed = [];
if (failed.length && !r._server_messages) {
frappe.throw(
__("Cannot update {0}", [
failed.map((f) => (f.bold ? f.bold() : f)).join(", "),
])
);
} else {
frappe.msgprint({
title: __("Success"),
message: __("Updated Successfully"),
indicator: "green",
});
}
if (failed.length && !r._server_messages) {
frappe.throw(
__("Cannot update {0}", [
failed.map((f) => (f.bold ? f.bold() : f)).join(", "),
])
);
} else {
frappe.msgprint({
title: __("Success"),
message: __("Updated Successfully"),
indicator: "green",
});
}
frappe.hide_progress();
frm.save();
});
frappe.hide_progress();
frm.save();
});
}
});
},

View file

@ -10,26 +10,24 @@ from frappe.utils.scheduler import is_scheduler_inactive
class BulkUpdate(Document):
pass
@frappe.whitelist()
def bulk_update(self):
self.check_permission("write")
limit = self.limit if self.limit and cint(self.limit) < 500 else 500
condition = ""
if self.condition:
if ";" in self.condition:
frappe.throw(_("; not allowed in condition"))
@frappe.whitelist()
def update(doctype, field, value, condition="", limit=500):
if not limit or cint(limit) > 500:
limit = 500
condition = f" where {self.condition}"
if condition:
condition = " where " + condition
if ";" in condition:
frappe.throw(_("; not allowed in condition"))
docnames = frappe.db.sql_list(
f"""select name from `tab{doctype}`{condition} limit {limit} offset 0"""
)
data = {}
data[field] = value
return submit_cancel_or_update_docs(doctype, docnames, "update", data)
docnames = frappe.db.sql_list(
f"""select name from `tab{self.document_type}`{condition} limit {limit} offset 0"""
)
return submit_cancel_or_update_docs(
self.document_type, docnames, "update", {self.field: self.update_value}
)
@frappe.whitelist()

View file

@ -93,10 +93,17 @@ def add(args=None):
doc = frappe.get_doc(args["doctype"], args["name"])
# if assignee does not have permissions, share
# if assignee does not have permissions, share or inform
if not frappe.has_permission(doc=doc, user=assign_to):
frappe.share.add(doc.doctype, doc.name, assign_to)
shared_with_users.append(assign_to)
if frappe.get_system_settings("disable_document_sharing"):
msg = _("User {0} is not permitted to access this document.").format(frappe.bold(assign_to))
msg += "<br>" + _(
"As document sharing is disabled, please give them the required permissions before assigning."
)
frappe.throw(msg, title=_("Missing Permission"))
else:
frappe.share.add(doc.doctype, doc.name, assign_to)
shared_with_users.append(assign_to)
# make this document followed by assigned user
if frappe.get_cached_value("User", assign_to, "follow_assigned_documents"):

View file

@ -11,7 +11,7 @@ def sendmail_to_system_managers(subject, content):
@frappe.whitelist()
def get_contact_list(txt, page_length=20) -> list[dict]:
"""Returns contacts (from autosuggest)"""
"""Return email ids for a multiselect field."""
if cached_contacts := get_cached_contacts(txt):
return cached_contacts[:page_length]
@ -19,11 +19,14 @@ def get_contact_list(txt, page_length=20) -> list[dict]:
reportview_conditions = build_match_conditions("Contact")
match_conditions = f"and {reportview_conditions}" if reportview_conditions else ""
# The multiselect field will store the `label` as the selected value.
# The `value` is just used as a unique key to distinguish between the options.
# https://github.com/frappe/frappe/blob/6c6a89bcdd9454060a1333e23b855d0505c9ebc2/frappe/public/js/frappe/form/controls/autocomplete.js#L29-L35
out = frappe.db.sql(
f"""select email_id as value,
f"""select name as value, email_id as label,
concat(first_name, ifnull(concat(' ',last_name), '' )) as description
from tabContact
where name like %(txt)s or email_id like %(txt)s
where (name like %(txt)s or email_id like %(txt)s) and email_id != ''
{match_conditions}
limit %(page_length)s""",
{"txt": f"%{txt}%", "page_length": page_length},

View file

@ -325,7 +325,7 @@ class EmailAccount(Document):
if _raise_error:
frappe.throw(
_("Please setup default Email Account from Setup > Email > Email Account"),
_("Please setup default Email Account from Settings > Email Account"),
frappe.OutgoingEmailError,
)

View file

@ -69,9 +69,7 @@ class SMTPServer:
if not self.server:
frappe.msgprint(
_(
"Email Account not setup. Please create a new Email Account from Setup > Email > Email Account"
),
_("Email Account not setup. Please create a new Email Account from Settings > Email Account"),
raise_exception=frappe.OutgoingEmailError,
)

View file

@ -488,6 +488,9 @@ def update_options_for_fieldtype(fieldtype: str, old: str, new: str) -> None:
if frappe.conf.developer_mode:
for name in frappe.get_all("DocField", filters={"options": old}, pluck="parent"):
if name in (old, new):
continue
doctype = frappe.get_doc("DocType", name)
save = False
for f in doctype.fields:
@ -496,11 +499,11 @@ def update_options_for_fieldtype(fieldtype: str, old: str, new: str) -> None:
save = True
if save:
doctype.save()
else:
DocField = frappe.qb.DocType("DocField")
frappe.qb.update(DocField).set(DocField.options, new).where(
(DocField.fieldtype == fieldtype) & (DocField.options == old)
).run()
DocField = frappe.qb.DocType("DocField")
frappe.qb.update(DocField).set(DocField.options, new).where(
(DocField.fieldtype == fieldtype) & (DocField.options == old)
).run()
frappe.qb.update(CustomField).set(CustomField.options, new).where(
(CustomField.fieldtype == fieldtype) & (CustomField.options == old)

View file

@ -77,6 +77,9 @@ def has_permission(
if user == "Administrator":
return True
if ptype == "share" and frappe.get_system_settings("disable_document_sharing"):
return False
if not doc and hasattr(doctype, "doctype"):
# first argument can be doc or doctype
doc = doctype

View file

@ -27,6 +27,7 @@ frappe.ui.form.ControlBarcode = class ControlBarcode extends frappe.ui.form.Cont
let svg = value;
let barcode_value = "";
this.set_empty_description();
if (value && value.startsWith("<svg")) {
barcode_value = $(svg).attr("data-barcode-value");
}
@ -44,10 +45,14 @@ frappe.ui.form.ControlBarcode = class ControlBarcode extends frappe.ui.form.Cont
if (value) {
// Get svg
const svg = this.barcode_area.find("svg")[0];
JsBarcode(svg, value, this.get_options(value));
$(svg).attr("data-barcode-value", value);
$(svg).attr("width", "100%");
return this.barcode_area.html();
try {
JsBarcode(svg, value, this.get_options(value));
$(svg).attr("data-barcode-value", value);
$(svg).attr("width", "100%");
return this.barcode_area.html();
} catch (e) {
this.set_description(`Invalid Barcode: ${String(e)}`);
}
}
}

View file

@ -186,7 +186,7 @@ frappe.form.formatters = {
return value;
}
if (value) {
value = frappe.datetime.str_to_user(value);
value = frappe.datetime.str_to_user(value, false, true);
// handle invalid date
if (value === "Invalid date") {
value = null;

View file

@ -1,49 +1,51 @@
<div class="clearfix"></div>
{% for(var i=0, l=contact_list.length; i<l; i++) { %}
{% for(const contact of contact_list) { %}
<div class="address-box">
<p class="h6 flex align-center">
{%= contact_list[i].first_name %} {%= contact_list[i].last_name %}
{% if(contact_list[i].is_primary_contact) { %}
{%= contact.first_name %} {%= contact.last_name %}
{% if(contact.is_primary_contact) { %}
<span class="text-muted">&nbsp;({%= __("Primary") %})</span>
{% } %}
{% if(contact_list[i].designation){ %}
<span class="text-muted">&ndash; {%= contact_list[i].designation %}</span>
{% if(contact.designation){ %}
<span class="text-muted">&ndash; {%= contact.designation %}</span>
{% } %}
<a href="/app/Form/Contact/{%= encodeURIComponent(contact_list[i].name) %}"
<a href="/app/Form/Contact/{%= encodeURIComponent(contact.name) %}"
class="btn btn-xs btn-default ml-auto">
{%= __("Edit") %}
</a>
</p>
{% if (contact_list[i].phones || contact_list[i].email_ids) { %}
{% if (contact.phone || contact.mobile_no || contact.phone_nos.length > 0) { %}
<p>
{% if(contact_list[i].phone) { %}
{%= __("Phone") %}: {%= contact_list[i].phone %}<span class="text-muted"> ({%= __("Primary") %})</span><br>
{% if(contact.phone) { %}
<a href="tel:{%= frappe.utils.escape_html(contact.phone) %}">{%= frappe.utils.escape_html(contact.phone) %}</a> &#183; <span class="text-muted">{%= __("Primary Phone") %}</span><br>
{% endif %}
{% if(contact_list[i].mobile_no) { %}
{%= __("Mobile No") %}: {%= contact_list[i].mobile_no %}<span class="text-muted"> ({%= __("Primary") %})</span><br>
{% if(contact.mobile_no) { %}
<a href="tel:{%= frappe.utils.escape_html(contact.mobile_no) %}">{%= frappe.utils.escape_html(contact.mobile_no) %}</a> &#183; <span class="text-muted">{%= __("Primary Mobile") %}</span><br>
{% endif %}
{% if(contact_list[i].phone_nos) { %}
{% for(var j=0, k=contact_list[i].phone_nos.length; j<k; j++) { %}
{%= __("Phone") %}: {%= contact_list[i].phone_nos[j].phone %}<br>
{% } %}
{% endif %}
</p>
<p>
{% if(contact_list[i].email_id) { %}
{%= __("Email") %}: {%= contact_list[i].email_id %}<span class="text-muted"> ({%= __("Primary") %})</span><br>
{% endif %}
{% if(contact_list[i].email_ids) { %}
{% for(var j=0, k=contact_list[i].email_ids.length; j<k; j++) { %}
{%= __("Email") %}: {%= contact_list[i].email_ids[j].email_id %}<br>
{% if(contact.phone_nos) { %}
{% for(const phone_no of contact.phone_nos) { %}
<a href="tel:{%= frappe.utils.escape_html(phone_no.phone) %}">{%= frappe.utils.escape_html(phone_no.phone) %}</a><br>
{% } %}
{% endif %}
</p>
{% endif %}
{% if (contact.email_id || contact.email_ids.length > 0) { %}
<p>
{% if (contact_list[i].address) { %}
{%= __("Address") %}: {%= contact_list[i].address %}<br>
{% endif %}
{% if(contact.email_id) { %}
<a href="mailto:{%= frappe.utils.escape_html(contact.email_id) %}">{%= frappe.utils.escape_html(contact.email_id) %}</a> &#183; <span class="text-muted">{%= __("Primary Email") %}</span><br>
{% endif %}
{% if(contact.email_ids) { %}
{% for(const email_id of contact.email_ids) { %}
<a href="mailto:{%= frappe.utils.escape_html(email_id.email_id) %}">{%= frappe.utils.escape_html(email_id.email_id) %}</a><br>
{% } %}
{% endif %}
</p>
{% endif %}
{% if (contact.address) { %}
<p>
{%= contact.address %}
</p>
{% endif %}
</div>
{% } %}
{% if(!contact_list.length) { %}
@ -51,4 +53,4 @@
{% } %}
<p><button class="btn btn-xs btn-default btn-contact">
{{ __("New Contact") }}</button>
</p>
</p>

View file

@ -38,6 +38,8 @@ frappe.views.ListSidebar = class ListSidebar {
this.reload_stats();
});
}
this.add_insights_banner();
}
setup_views() {
@ -239,4 +241,43 @@ frappe.views.ListSidebar = class ListSidebar {
this.sidebar.find(".stat-no-records").remove();
this.get_stats();
}
add_insights_banner() {
try {
if (this.list_view.view != "Report") {
return;
}
if (localStorage.getItem("show_insights_banner") == "false") {
return;
}
if (this.insights_banner) {
this.insights_banner.remove();
}
const message = "Get more insights from your data with Frappe Insights.";
const link = "https://frappe.io/s/insights";
const cta = "Get Frappe Insights";
this.insights_banner = $(`
<div style="position: relative;">
<div class="">
${message}
</div>
<div class="mt-2">
<a href="${link}" target="_blank" style="color: var(--primary-color)">${cta} -> </a>
</div>
<div style="position: absolute; top: 0px; right: 0px; cursor: pointer;" title="Dismiss"
onclick="localStorage.setItem('show_insights_banner', 'false') || this.parentElement.remove()">
<svg class="icon icon-sm" style="">
<use class="" href="#icon-close"></use>
</svg>
</div>
</div>
`).appendTo(this.sidebar);
} catch (error) {
console.error(error);
}
}
};

View file

@ -447,6 +447,12 @@ $.extend(frappe.model, {
},
can_share: function (doctype, frm) {
let disable_sharing = cint(frappe.sys_defaults.disable_document_sharing);
if (disable_sharing && frappe.session.user !== "Administrator") {
return false;
}
if (frm) {
return frm.perm[0].share === 1;
}

View file

@ -208,7 +208,6 @@ body {
// Overrides for each widgets
&.dashboard-widget-box {
min-height: 240px;
padding: var(--padding-md) var(--padding-lg);
.filter-chart {
background-color: var(--control-bg);
@ -238,13 +237,16 @@ body {
}
.widget-head {
padding: var(--padding-sm);
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px;
}
.widget-body {
padding-top: 7px;
}
.widget-control {
display: flex;
align-items: center;
@ -560,7 +562,7 @@ body {
}
&.links-widget-box {
padding: 18px 12px;
padding: 12px 7px;
.link-item {
display: flex;

View file

@ -12,6 +12,7 @@ from frappe.utils import get_table_name
class Base:
terms = terms
desc = Order.desc
asc = Order.asc
Schema = Schema
Table = Table

View file

@ -8,6 +8,7 @@ import unittest
from io import StringIO
from unittest.mock import patch
import git
import yaml
import frappe
@ -134,6 +135,9 @@ class TestBoilerPlate(unittest.TestCase):
self.check_parsable_python_files(new_app_dir)
app_repo = git.Repo(new_app_dir)
self.assertEqual(app_repo.active_branch.name, "develop")
def test_create_app_without_git_init(self):
app_name = "test_app_no_git"

View file

@ -4,7 +4,7 @@ from unittest.mock import MagicMock
import frappe
from frappe.tests.test_api import FrappeAPITestCase
from frappe.tests.utils import FrappeTestCase
from frappe.utils.caching import request_cache, site_cache
from frappe.utils.caching import redis_cache, request_cache, site_cache
CACHE_TTL = 4
external_service = MagicMock(return_value=30)
@ -82,13 +82,84 @@ class TestSiteCache(FrappeAPITestCase):
api_with_ttl = f"{module}.ping_with_ttl"
api_without_ttl = f"{module}.ping"
start = time.monotonic()
for _ in range(5):
self.get(f"/api/method/{api_with_ttl}")
self.get(f"/api/method/{api_without_ttl}")
end = time.monotonic()
self.assertEqual(register_with_external_service.call_count, 2)
time.sleep(CACHE_TTL - (end - start))
time.sleep(CACHE_TTL)
self.get(f"/api/method/{api_with_ttl}")
self.assertEqual(register_with_external_service.call_count, 3)
class TestRedisCache(FrappeAPITestCase):
def test_redis_cache(self):
function_call_count = 0
@redis_cache(ttl=CACHE_TTL)
def calculate_area(radius: float) -> float:
nonlocal function_call_count
function_call_count += 1
return 3.14 * radius**2
self.assertEqual(calculate_area(10), 314)
self.assertEqual(function_call_count, 1)
self.assertEqual(calculate_area(10), 314)
self.assertEqual(function_call_count, 1)
time.sleep(CACHE_TTL)
self.assertEqual(calculate_area(10), 314)
self.assertEqual(function_call_count, 2)
calculate_area.clear_cache()
self.assertEqual(calculate_area(10), 314)
self.assertEqual(function_call_count, 3)
calculate_area.clear_cache()
def test_redis_cache_without_params(self):
function_call_count = 0
@redis_cache
def calculate_area(radius: float) -> float:
nonlocal function_call_count
function_call_count += 1
return 3.14 * radius**2
calculate_area.clear_cache()
self.assertEqual(calculate_area(10), 314)
self.assertEqual(function_call_count, 1)
calculate_area.clear_cache()
self.assertEqual(calculate_area(10), 314)
self.assertEqual(function_call_count, 2)
calculate_area.clear_cache()
def test_redis_cache_diff_args(self):
function_call_count = 0
@redis_cache(ttl=CACHE_TTL)
def calculate_area(radius: float) -> float:
nonlocal function_call_count
function_call_count += 1
return 3.14 * radius**2
self.assertEqual(calculate_area(10), 314)
self.assertEqual(function_call_count, 1)
self.assertEqual(calculate_area(100), 31400)
self.assertEqual(function_call_count, 2)
self.assertEqual(calculate_area(5), 25 * 3.14)
self.assertEqual(function_call_count, 3)
calculate_area(10)
# from cache now
self.assertEqual(function_call_count, 3)
calculate_area(radius=10)
# args, kwargs are treated differently
self.assertEqual(function_call_count, 4)
calculate_area(radius=10)
# kwargs should hit cache too
self.assertEqual(function_call_count, 4)

View file

@ -8,6 +8,7 @@ from frappe.core.doctype.doctype.test_doctype import new_doctype
from frappe.query_builder import Field
from frappe.query_builder.functions import Max
from frappe.tests.utils import FrappeTestCase
from frappe.utils import random_string
from frappe.utils.nestedset import (
NestedSetChildExistsError,
NestedSetInvalidMergeError,
@ -213,6 +214,12 @@ class TestNestedSet(FrappeTestCase):
remove_subtree("Test Tree DocType", "Parent 2")
self.test_basic_tree()
def test_rename_nestedset(self):
doctype = new_doctype(is_tree=True).insert()
# Rename doctype
frappe.rename_doc("DocType", doctype.name, "Test " + random_string(10), force=True)
def test_merge_groups(self):
global records
el = {"some_fieldname": "Parent 2", "parent_test_tree_doctype": "Root Node", "is_group": 1}

View file

@ -9,14 +9,9 @@ from unittest.mock import patch
import frappe
from frappe.core.doctype.doctype.test_doctype import new_doctype
from frappe.exceptions import DoesNotExistError, ValidationError
from frappe.exceptions import DoesNotExistError
from frappe.model.base_document import get_controller
from frappe.model.rename_doc import (
bulk_rename,
get_fetch_fields,
update_document_title,
update_linked_doctypes,
)
from frappe.model.rename_doc import bulk_rename, update_document_title
from frappe.modules.utils import get_doc_path
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_to_date, now
@ -255,14 +250,16 @@ class TestRenameDoc(FrappeTestCase):
)
def test_deprecated_utils(self):
from frappe.model.rename_doc import get_fetch_fields, update_linked_doctypes
stdout = StringIO()
with redirect_stdout(stdout), patch_db(["set_value"]):
get_fetch_fields("User", "ToDo", ["Activity Log"])
self.assertTrue("Function frappe.model.rename_doc.get_fetch_fields" in stdout.getvalue())
self.assertIn("Function frappe.model.rename_doc.get_fetch_fields", stdout.getvalue())
update_linked_doctypes("User", "ToDo", "str", "str")
self.assertTrue("Function frappe.model.rename_doc.update_linked_doctypes" in stdout.getvalue())
self.assertIn("Function frappe.model.rename_doc.update_linked_doctypes", stdout.getvalue())
def test_doc_rename_method(self):
name = choice(self.available_documents)

View file

@ -426,12 +426,15 @@ def create_blog_post():
return doc
def create_test_user():
if frappe.db.exists("User", UI_TEST_USER):
@whitelist_for_tests
def create_test_user(username=None):
name = username or UI_TEST_USER
if frappe.db.exists("User", name):
return
user = frappe.new_doc("User")
user.email = UI_TEST_USER
user.email = name
user.first_name = "Frappe"
user.new_password = frappe.local.conf.admin_password
user.send_welcome_email = 0

View file

@ -143,7 +143,7 @@ def change_settings(doctype, settings_dict):
# change setting
for key, value in settings_dict.items():
setattr(settings, key, value)
settings.save()
settings.save(ignore_permissions=True)
# singles are cached by default, clear to avoid flake
frappe.db.value_cache[settings] = {}
yield # yield control to calling function
@ -153,7 +153,7 @@ def change_settings(doctype, settings_dict):
settings = frappe.get_doc(doctype)
for key, value in previous_settings.items():
setattr(settings, key, value)
settings.save()
settings.save(ignore_permissions=True)
def timeout(seconds=30, error_message="Test timed out."):

View file

@ -52,6 +52,7 @@ Content,Inhalt,
Content Type,Inhaltstyp,
Create,Erstellen,
Created By,Erstellt von,
Crop,Zuschneiden,
Current,Laufend,
Custom HTML,Benutzerdefiniertes HTML,
Custom?,Benutzerdefiniert?,
@ -64,6 +65,7 @@ Delivery Status,Lieferstatus,
Department,Abteilung,
Details,Details,
Document Name,Dokumentenname,
Document Naming Settings,Dokumentenbenennungseinstellungen,
Document Status,Dokumentenstatus,
Document Type,Dokumententyp,
Domain,Domäne,
@ -1383,6 +1385,7 @@ Inverse,Invertieren,
Is,Ist,
Is Attachments Folder,Ist Ordner für Anhänge,
Is Child Table,Ist Untertabelle,
Is Custom,Ist benutzerdefiniert,
Is Custom Field,Ist benutzerdefiniertes Feld,
Is First Startup,Ist Erstes Startup,
Is Folder,Ist Ordner,
@ -1613,6 +1616,7 @@ Naming,Bezeichnung,
Naming Rule, Benennungsregel,
"Naming Options:\n<ol><li><b>field:[fieldname]</b> - By Field</li><li><b>naming_series:</b> - By Naming Series (field called naming_series must be present</li><li><b>Prompt</b> - Prompt user for a name</li><li><b>[series]</b> - Series by prefix (separated by a dot); for example PRE.#####</li>\n<li><b>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</b> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.</li></ol>","Namensoptionen: <ol><li> <b>Feld: [Feldname]</b> - Nach Feld </li><li> <b>naming_series:</b> - Nach der <b>Namensreihe</b> (das Feld naming_series muss vorhanden sein) </li><li> <b>Eingabeaufforderung</b> - Benutzer nach einem Namen fragen </li><li> <b>[Serie]</b> - Reihe nach Präfix (getrennt durch einen Punkt); zum Beispiel PRE. ##### </li><li> <b>Format: BEISPIEL- {MM} morewords {Feldname1} - {Feldname2} - {#####}</b> - Ersetzt alle verspannten Wörter (Feldnamen, Datumsworte (DD, MM, YY), Serien) durch ihren Wert. Außerhalb von Klammern können beliebige Zeichen verwendet werden. </li></ol>",
Naming Series mandatory,Nummernkreis zwingend erforderlich,
Navigation Settings,Navigationseinstellungen,
Nested set error. Please contact the Administrator.,Schachtelfehler. Bitte den Administrator kontaktieren.,
New Activity,Neue Aktivität,
New Chat,Neuer Chat,
@ -1720,11 +1724,11 @@ Note: Changing the Page Name will break previous URL to this page.,"Hinweis: Wen
Note: Multiple sessions will be allowed in case of mobile device,Hinweis: Mehrere Sitzungen wird im Falle einer mobilen Gerät erlaubt sein,
Nothing to show,Nichts anzuzeigen,
Nothing to update,Nichts zu aktualisieren,
Notification,Mitteilung,
Notification,Benachrichtigung,
Notification Recipient,Benachrichtigungsempfänger,
Notification Tones,Benachrichtigungstöne,
Notifications,Benachrichtigungen,
Notifications and bulk mails will be sent from this outgoing server.,Hinweise und Massen-E-Mails werden von diesem Postausgangsserver versendet.,
Notifications and bulk mails will be sent from this outgoing server.,Benachrichtigungen und Massen-E-Mails werden von diesem Postausgangsserver versendet.,
Notify Users On Every Login,Benutzer bei jeder Anmeldung benachrichtigen,
Notify if unreplied,"Benachrichtigen, wenn unbeantwortet",
Notify if unreplied for (in mins),"Benachrichtigen, wenn unbeantwortet für (in Minuten)",
@ -2323,6 +2327,7 @@ Show more details,Weiteres,
Show only errors,Zeige nur Fehler,
"Show title in browser window as ""Prefix - title""","Diesen Eintrag im Browser-Fenster als ""Präfix - Titel"" anzeigen",
Showing only Numeric fields from Report,Nur numerische Felder aus Bericht anzeigen,
Sidebar,Seitenleiste,
Sidebar Items,Elemente der Seitenleiste,
Sidebar Settings,Sidebar-Einstellungen,
Sidebar and Comments,Sidebar und Kommentare,

1 A4 A4
52 Content Type Inhaltstyp
53 Create Erstellen
54 Created By Erstellt von
55 Crop Zuschneiden
56 Current Laufend
57 Custom HTML Benutzerdefiniertes HTML
58 Custom? Benutzerdefiniert?
65 Department Abteilung
66 Details Details
67 Document Name Dokumentenname
68 Document Naming Settings Dokumentenbenennungseinstellungen
69 Document Status Dokumentenstatus
70 Document Type Dokumententyp
71 Domain Domäne
1385 Is Ist
1386 Is Attachments Folder Ist Ordner für Anhänge
1387 Is Child Table Ist Untertabelle
1388 Is Custom Ist benutzerdefiniert
1389 Is Custom Field Ist benutzerdefiniertes Feld
1390 Is First Startup Ist Erstes Startup
1391 Is Folder Ist Ordner
1616 Naming Rule Benennungsregel
1617 Naming Options:\n<ol><li><b>field:[fieldname]</b> - By Field</li><li><b>naming_series:</b> - By Naming Series (field called naming_series must be present</li><li><b>Prompt</b> - Prompt user for a name</li><li><b>[series]</b> - Series by prefix (separated by a dot); for example PRE.#####</li>\n<li><b>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</b> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.</li></ol> Namensoptionen: <ol><li> <b>Feld: [Feldname]</b> - Nach Feld </li><li> <b>naming_series:</b> - Nach der <b>Namensreihe</b> (das Feld naming_series muss vorhanden sein) </li><li> <b>Eingabeaufforderung</b> - Benutzer nach einem Namen fragen </li><li> <b>[Serie]</b> - Reihe nach Präfix (getrennt durch einen Punkt); zum Beispiel PRE. ##### </li><li> <b>Format: BEISPIEL- {MM} morewords {Feldname1} - {Feldname2} - {#####}</b> - Ersetzt alle verspannten Wörter (Feldnamen, Datumsworte (DD, MM, YY), Serien) durch ihren Wert. Außerhalb von Klammern können beliebige Zeichen verwendet werden. </li></ol>
1618 Naming Series mandatory Nummernkreis zwingend erforderlich
1619 Navigation Settings Navigationseinstellungen
1620 Nested set error. Please contact the Administrator. Schachtelfehler. Bitte den Administrator kontaktieren.
1621 New Activity Neue Aktivität
1622 New Chat Neuer Chat
1724 Note: Multiple sessions will be allowed in case of mobile device Hinweis: Mehrere Sitzungen wird im Falle einer mobilen Gerät erlaubt sein
1725 Nothing to show Nichts anzuzeigen
1726 Nothing to update Nichts zu aktualisieren
1727 Notification Mitteilung Benachrichtigung
1728 Notification Recipient Benachrichtigungsempfänger
1729 Notification Tones Benachrichtigungstöne
1730 Notifications Benachrichtigungen
1731 Notifications and bulk mails will be sent from this outgoing server. Hinweise und Massen-E-Mails werden von diesem Postausgangsserver versendet. Benachrichtigungen und Massen-E-Mails werden von diesem Postausgangsserver versendet.
1732 Notify Users On Every Login Benutzer bei jeder Anmeldung benachrichtigen
1733 Notify if unreplied Benachrichtigen, wenn unbeantwortet
1734 Notify if unreplied for (in mins) Benachrichtigen, wenn unbeantwortet für (in Minuten)
2327 Show only errors Zeige nur Fehler
2328 Show title in browser window as "Prefix - title" Diesen Eintrag im Browser-Fenster als "Präfix - Titel" anzeigen
2329 Showing only Numeric fields from Report Nur numerische Felder aus Bericht anzeigen
2330 Sidebar Seitenleiste
2331 Sidebar Items Elemente der Seitenleiste
2332 Sidebar Settings Sidebar-Einstellungen
2333 Sidebar and Comments Sidebar und Kommentare

View file

@ -450,12 +450,20 @@ def disable():
@frappe.whitelist()
def reset_otp_secret(user):
def reset_otp_secret(user: str):
if frappe.session.user != user:
frappe.only_for("System Manager", message=True)
otp_issuer = frappe.db.get_single_value("System Settings", "otp_issuer_name")
user_email = frappe.db.get_value("User", user, "email")
settings = frappe.get_cached_doc("System Settings")
if not settings.enable_two_factor_auth:
frappe.throw(
_("You have to enable Two Factor Auth from System Settings."),
title=_("Enable Two Factor Auth"),
)
otp_issuer = settings.otp_issuer_name or "Frappe Framework"
user_email = frappe.get_cached_value("User", user, "email")
clear_default(user + "_otplogin")
clear_default(user + "_otpsecret")
@ -463,10 +471,10 @@ def reset_otp_secret(user):
email_args = {
"recipients": user_email,
"sender": None,
"subject": _("OTP Secret Reset - {0}").format(otp_issuer or "Frappe Framework"),
"subject": _("OTP Secret Reset - {0}").format(otp_issuer),
"message": _(
"<p>Your OTP secret on {0} has been reset. If you did not perform this reset and did not request it, please contact your System Administrator immediately.</p>"
).format(otp_issuer or "Frappe Framework"),
).format(otp_issuer),
"delayed": False,
"retry": 3,
}

View file

@ -1031,8 +1031,13 @@ def groupby_metric(iterable: dict[str, list], key: str):
return records
def get_table_name(table_name: str) -> str:
return f"tab{table_name}" if not table_name.startswith("__") else table_name
def get_table_name(table_name: str, wrap_in_backticks: bool = False) -> str:
name = f"tab{table_name}" if not table_name.startswith("__") else table_name
if wrap_in_backticks:
return f"`{name}`"
return name
def squashify(what):

View file

@ -150,7 +150,7 @@ def _create_app_boilerplate(dest, hooks, no_git=False):
f.write(frappe.as_unicode(gitignore_template.format(app_name=hooks.app_name)))
# initialize git repository
app_repo = git.Repo.init(app_directory)
app_repo = git.Repo.init(app_directory, initial_branch="develop")
app_repo.git.add(A=True)
app_repo.index.commit("feat: Initialize App")

View file

@ -128,3 +128,39 @@ def site_cache(ttl: int | None = None, maxsize: int | None = None) -> Callable:
return time_cache_wrapper(ttl)
return time_cache_wrapper
def redis_cache(ttl: int | None = 3600, user: str | bool | None = None) -> Callable:
"""Decorator to cache method calls and its return values in Redis
args:
ttl: time to expiry in seconds, defaults to 1 hour
user: `true` should cache be specific to session user.
"""
def wrapper(func: Callable = None) -> Callable:
func_key = f"{func.__module__}.{func.__qualname__}"
def clear_cache():
frappe.cache().delete_keys(func_key)
func.clear_cache = clear_cache
func.ttl = ttl if not callable(ttl) else 3600
@wraps(func)
def redis_cache_wrapper(*args, **kwargs):
func_call_key = func_key + str(__generate_request_cache_key(args, kwargs))
if frappe.cache().exists(func_call_key):
return frappe.cache().get_value(func_call_key, user=user)
else:
val = func(*args, **kwargs)
ttl = getattr(func, "ttl", 3600)
frappe.cache().set_value(func_call_key, val, expires_in_sec=ttl, user=user)
return val
return redis_cache_wrapper
if callable(ttl):
return wrapper(ttl)
return wrapper

View file

@ -195,6 +195,10 @@ class RedisWrapper(redis.Redis):
except redis.exceptions.ConnectionError:
return False
def exists(self, *names: str, user=None, shared=None) -> int:
names = [self.make_key(n, user=user, shared=shared) for n in names]
return super().exists(*names)
def hgetall(self, name):
value = super().hgetall(self.make_key(name))
return {key: pickle.loads(value) for key, value in value.items()}

View file

@ -11,7 +11,7 @@ import frappe
from frappe import _, get_module_path
from frappe.core.doctype.access_log.access_log import make_access_log
from frappe.core.doctype.document_share_key.document_share_key import is_expired
from frappe.utils import cint, sanitize_html, strip_html
from frappe.utils import cint, escape_html, strip_html
from frappe.utils.jinja_globals import is_rtl
if TYPE_CHECKING:
@ -27,12 +27,11 @@ def get_context(context):
"""Build context for print"""
if not ((frappe.form_dict.doctype and frappe.form_dict.name) or frappe.form_dict.doc):
return {
"body": sanitize_html(
"""<h1>Error</h1>
"body": f"""
<h1>Error</h1>
<p>Parameters doctype and name required</p>
<pre>%s</pre>"""
% repr(frappe.form_dict)
)
<pre>{escape_html(frappe.as_json(frappe.form_dict, indent=2))}</pre>
"""
}
if frappe.form_dict.doc:

View file

@ -16,7 +16,7 @@ dependencies = [
"Jinja2~=3.1.2",
"Pillow~=9.3.0",
"PyJWT~=2.4.0",
"PyMySQL~=1.0.2",
"PyMySQL==1.0.3",
"PyPDF2~=2.1.0",
"PyPika~=0.48.9",
"PyQRCode~=1.2.1",
@ -57,7 +57,7 @@ dependencies = [
"python-dateutil~=2.8.1",
"pytz==2022.1",
"rauth~=0.7.3",
"redis~=4.3.4",
"redis~=4.5.4",
"hiredis~=2.0.0",
"requests-oauthlib~=1.3.0",
"requests~=2.27.1",