diff --git a/cypress/integration/control_duration.js b/cypress/integration/control_duration.js
index a391eec7c10..889e68d12e2 100644
--- a/cypress/integration/control_duration.js
+++ b/cypress/integration/control_duration.js
@@ -20,11 +20,14 @@ context("Control Duration", () => {
it("should set duration", () => {
get_dialog_with_duration().as("dialog");
+ cy.wait(500);
cy.get(".frappe-control[data-fieldname=duration] input").first().click();
cy.get(".duration-input[data-duration=days]")
.type(45, { force: true })
.blur({ force: true });
+ cy.wait(500);
cy.get(".duration-input[data-duration=minutes]").type(30).blur({ force: true });
+ cy.wait(500);
cy.get(".frappe-control[data-fieldname=duration] input")
.first()
.should("have.value", "45d 30m");
diff --git a/cypress/integration/dashboard_links.js b/cypress/integration/dashboard_links.js
deleted file mode 100644
index ebcdfa00489..00000000000
--- a/cypress/integration/dashboard_links.js
+++ /dev/null
@@ -1,94 +0,0 @@
-import doctype_with_child_table from "../fixtures/doctype_with_child_table";
-import child_table_doctype from "../fixtures/child_table_doctype";
-import child_table_doctype_1 from "../fixtures/child_table_doctype_1";
-import doctype_to_link from "../fixtures/doctype_to_link";
-const doctype_to_link_name = doctype_to_link.name;
-const child_table_doctype_name = child_table_doctype.name;
-
-context("Dashboard links", () => {
- before(() => {
- cy.visit("/login");
- cy.login("Administrator");
- cy.insert_doc("DocType", child_table_doctype, true);
- cy.insert_doc("DocType", child_table_doctype_1, true);
- cy.insert_doc("DocType", doctype_with_child_table, true);
- cy.insert_doc("DocType", doctype_to_link, true);
- return cy
- .window()
- .its("frappe")
- .then((frappe) => {
- frappe.call("frappe.tests.ui_test_helpers.update_child_table", {
- name: child_table_doctype_name,
- });
- });
- });
-
- it("Adding a new contact, checking for the counter on the dashboard and deleting the created contact", () => {
- cy.visit("/app/contact");
- cy.clear_filters();
-
- cy.visit(`/app/user/${cy.config("testUser")}`);
-
- //To check if initially the dashboard contains only the "Contact" link and there is no counter
- cy.select_form_tab("Connections");
- cy.get('[data-doctype="Contact"]').should("contain", "Contact");
-
- //Adding a new contact
- cy.get('.document-link-badge[data-doctype="Contact"]').click();
- cy.wait(300);
- cy.findByRole("button", { name: "Add Contact" }).should("be.visible");
- cy.findByRole("button", { name: "Add Contact" }).click();
- cy.get('[data-doctype="Contact"][data-fieldname="first_name"]').type("Admin");
- cy.findByRole("button", { name: "Save" }).click();
- cy.visit(`/app/user/${cy.config("testUser")}`);
-
- //To check if the counter for contact doc is "2" after adding additional contact
- cy.select_form_tab("Connections");
- cy.get('[data-doctype="Contact"] > .count').should("contain", "2");
- cy.get('[data-doctype="Contact"]').contains("Contact").click();
-
- //Deleting the newly created contact
- cy.visit("/app/contact");
- cy.get(".list-subject > .select-like > .list-row-checkbox").eq(0).click({ force: true });
- cy.findByRole("button", { name: "Actions" }).click();
- cy.get('.actions-btn-group [data-label="Delete"]').click();
- cy.findByRole("button", { name: "Yes" }).click({ delay: 700 });
-
- //To check if the counter from the "Contact" doc link is removed
- cy.wait(700);
- cy.visit("/app/user");
- cy.get(".list-row-col > .level-item > .ellipsis").eq(0).click({ force: true });
- cy.get('[data-doctype="Contact"]').should("contain", "Contact");
- });
-
- it("Report link in dashboard", () => {
- cy.visit(`/app/user/${cy.config("testUser")}`);
- cy.select_form_tab("Connections");
- cy.get('.document-link[data-doctype="Contact"]').contains("Contact");
- cy.window()
- .its("cur_frm")
- .then((cur_frm) => {
- cur_frm.dashboard.data.reports = [
- {
- label: "Reports",
- items: ["Website Analytics"],
- },
- ];
- cur_frm.dashboard.render_report_links();
- cy.get('.document-link[data-report="Website Analytics"]')
- .contains("Website Analytics")
- .click();
- });
- });
-
- it("check if child table is populated with linked field on creation from dashboard link", () => {
- cy.new_form(doctype_to_link_name);
- cy.fill_field("title", "Test Linking");
- cy.findByRole("button", { name: "Save" }).click();
-
- cy.get(".document-link .btn-new").click();
- cy.get(
- '.frappe-control[data-fieldname="child_table"] .rows .data-row .col[data-fieldname="doctype_to_link"]'
- ).should("contain.text", "Test Linking");
- });
-});
diff --git a/cypress/integration/discussions.js b/cypress/integration/discussions.js
deleted file mode 100644
index 9caeddeb1f9..00000000000
--- a/cypress/integration/discussions.js
+++ /dev/null
@@ -1,85 +0,0 @@
-context("Discussions", () => {
- before(() => {
- cy.login();
- cy.visit("/app");
- return cy
- .window()
- .its("frappe")
- .then((frappe) => {
- return frappe.call("frappe.tests.ui_test_helpers.create_data_for_discussions");
- });
- });
-
- const reply_through_modal = () => {
- cy.visit("/test-page-discussions");
-
- // Open the modal
- cy.get(".reply").click();
- cy.wait(500);
- cy.get(".discussion-modal").should("be.visible");
-
- // Enter title
- cy.get(".modal .topic-title")
- .type("Discussion from tests")
- .should("have.value", "Discussion from tests");
-
- // Enter comment
- cy.get(".modal .discussions-comment").type(
- "This is a discussion from the cypress ui tests."
- );
-
- // Submit
- cy.get(".modal .submit-discussion").click();
- cy.wait(2000);
-
- // Check if discussion is added to page and content is visible
- cy.get(".sidebar-parent:first .discussion-topic-title").should(
- "have.text",
- "Discussion from tests"
- );
- cy.get(".discussion-on-page:visible").should("have.class", "show");
- cy.get(".discussion-on-page:visible .reply-card .reply-text .ql-editor p").should(
- "have.text",
- "This is a discussion from the cypress ui tests."
- );
- };
-
- const reply_through_comment_box = () => {
- cy.get(".discussion-form:visible .discussions-comment").type(
- "This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page."
- );
-
- cy.get(".discussion-form:visible .submit-discussion").click();
- cy.wait(3000);
- cy.get(".discussion-on-page:visible").should("have.class", "show");
- cy.get(".discussion-on-page:visible")
- .children(".reply-card")
- .eq(1)
- .find(".reply-text")
- .should(
- "have.text",
- "This is a discussion from the cypress ui tests. This comment was entered through the commentbox on the page.\n"
- );
- };
-
- const single_thread_discussion = () => {
- cy.visit("/test-single-thread");
- cy.get(".discussions-sidebar").should("have.length", 0);
- cy.get(".reply").should("have.length", 0);
-
- cy.get(".discussion-form:visible .discussions-comment").type(
- "This comment is being made on a single thread discussion."
- );
- cy.get(".discussion-form:visible .submit-discussion").click();
- cy.wait(3000);
- cy.get(".discussion-on-page")
- .children(".reply-card")
- .eq(-1)
- .find(".reply-text")
- .should("have.text", "This comment is being made on a single thread discussion.\n");
- };
-
- it("reply through modal", reply_through_modal);
- it("reply through comment box", reply_through_comment_box);
- it("single thread discussion", single_thread_discussion);
-});
diff --git a/cypress/integration/first_day_of_the_week.js b/cypress/integration/first_day_of_the_week.js
deleted file mode 100644
index 784c068f016..00000000000
--- a/cypress/integration/first_day_of_the_week.js
+++ /dev/null
@@ -1,51 +0,0 @@
-context("First Day of the Week", () => {
- before(() => {
- cy.login();
- });
-
- beforeEach(() => {
- cy.visit("/app/system-settings");
- cy.findByText("Date and Number Format").click();
- });
-
- it("Date control starts with same day as selected in System Settings", () => {
- cy.intercept(
- "POST",
- "/api/method/frappe.core.doctype.system_settings.system_settings.load"
- ).as("load_settings");
- cy.fill_field("first_day_of_the_week", "Tuesday", "Select");
- cy.findByRole("button", { name: "Save" }).click();
- cy.wait("@load_settings");
- cy.dialog({
- title: "Date",
- fields: [
- {
- label: "Date",
- fieldname: "date",
- fieldtype: "Date",
- },
- ],
- });
- cy.get_field("date").click();
- cy.get(".datepicker--day-name").eq(0).should("have.text", "Tu");
- });
-
- it("Calendar view starts with same day as selected in System Settings", () => {
- cy.intercept(
- "POST",
- "/api/method/frappe.core.doctype.system_settings.system_settings.load"
- ).as("load_settings");
- cy.fill_field("first_day_of_the_week", "Monday", "Select");
- cy.findByRole("button", { name: "Save" }).click();
- cy.wait("@load_settings");
- cy.visit("app/todo/view/calendar/default");
- cy.get(".fc-day-header > span").eq(0).should("have.text", "Mon");
- });
-
- after(() => {
- cy.visit("/app/system-settings");
- cy.findByText("Date and Number Format").click();
- cy.fill_field("first_day_of_the_week", "Sunday", "Select");
- cy.findByRole("button", { name: "Save" }).click();
- });
-});
diff --git a/cypress/integration/list_view_drag_select.js b/cypress/integration/list_view_drag_select.js
deleted file mode 100644
index 2dcb31372c0..00000000000
--- a/cypress/integration/list_view_drag_select.js
+++ /dev/null
@@ -1,49 +0,0 @@
-context("List View", () => {
- before(() => {
- cy.login();
- cy.go_to_list("DocType");
- });
-
- it("List view check rows on drag", () => {
- cy.get(".filter-x-button").click();
- cy.get(".list-row-checkbox").then(($checkbox) => {
- cy.wrap($checkbox).first().trigger("mousedown");
- cy.get(".level.list-row").each(($ele) => {
- cy.wrap($ele).trigger("mousemove");
- });
- cy.document().trigger("mouseup");
- });
-
- cy.get(".level.list-row .list-row-checkbox").each(($checkbox) => {
- cy.wrap($checkbox).should("be.checked");
- });
- });
-
- it("Check all rows are checked", () => {
- cy.get(".level.list-row .list-row-checkbox")
- .its("length")
- .then((len) => {
- cy.get(".level-item.list-header-meta")
- .should("be.visible")
- .should("contain.text", `${len} items selected`);
- });
- });
-
- it("List view uncheck rows on drag", () => {
- cy.get(".list-row-checkbox").then(($checkbox) => {
- cy.wrap($checkbox).first().trigger("mousedown");
- cy.get(".level.list-row").each(($ele) => {
- cy.wrap($ele).trigger("mousemove");
- });
- cy.document().trigger("mouseup");
- });
-
- cy.get(".level.list-row .list-row-checkbox").each(($checkbox) => {
- cy.wrap($checkbox).should("not.be.checked");
- });
- });
-
- it("Check all rows are unchecked", () => {
- cy.get(".level-item.list-header-meta").should("not.be.visible");
- });
-});
diff --git a/frappe/__init__.py b/frappe/__init__.py
index f95d45d9aa9..9a43c21ca17 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -500,7 +500,7 @@ def print_sql(enable: bool = True) -> None:
def log(msg: str) -> None:
- """Add to `debug_log`.
+ """Add to `debug_log`
:param msg: Message."""
if not request:
diff --git a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json
index 451c4108a0a..782c0749f87 100644
--- a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json
+++ b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json
@@ -7,7 +7,8 @@
"field_order": [
"status",
"scheduled_job_type",
- "details"
+ "details",
+ "debug_log"
],
"fields": [
{
@@ -35,10 +36,16 @@
"options": "Scheduled Job Type",
"read_only": 1,
"reqd": 1
+ },
+ {
+ "fieldname": "debug_log",
+ "fieldtype": "Code",
+ "label": "Debug Log",
+ "read_only": 1
}
],
"links": [],
- "modified": "2022-06-13 05:41:21.090972",
+ "modified": "2023-11-09 12:06:41.781270",
"modified_by": "Administrator",
"module": "Core",
"name": "Scheduled Job Log",
diff --git a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py
index a6e70f3b3ae..54a8ab1e52d 100644
--- a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py
+++ b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py
@@ -16,6 +16,7 @@ class ScheduledJobLog(Document):
if TYPE_CHECKING:
from frappe.types import DF
+ debug_log: DF.Code | None
details: DF.Code | None
scheduled_job_type: DF.Link
status: DF.Literal["Scheduled", "Complete", "Failed"]
diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py
index 8006a43e937..3fb251362ef 100644
--- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py
+++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py
@@ -164,6 +164,8 @@ def update_scheduler_log(self, status):
dict(doctype="Scheduled Job Log", scheduled_job_type=self.name)
).insert(ignore_permissions=True)
self.scheduler_log.db_set("status", status)
+ if frappe.debug_log:
+ self.scheduler_log.db_set("debug_log", "\n".join(frappe.debug_log))
if status == "Failed":
self.scheduler_log.db_set("details", frappe.get_traceback(with_context=True))
if status == "Start":
diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py
index fa56c4f5d42..c6c86c5b787 100644
--- a/frappe/core/doctype/user/test_user.py
+++ b/frappe/core/doctype/user/test_user.py
@@ -11,6 +11,7 @@
import frappe
import frappe.exceptions
from frappe.core.doctype.user.user import (
+ User,
handle_password_test_fail,
reset_password,
sign_up,
@@ -475,7 +476,7 @@ def test_user(
try:
first_name = first_name or frappe.generate_hash()
email = email or (first_name + "@example.com")
- user = frappe.new_doc(
+ user: User = frappe.new_doc(
"User",
send_welcome_email=0,
email=email,
diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js
index d1f8b784b20..592e6426f67 100644
--- a/frappe/core/doctype/user/user.js
+++ b/frappe/core/doctype/user/user.js
@@ -286,7 +286,7 @@ frappe.ui.form.on("User", {
frm.set_df_property("enabled", "read_only", 0);
}
- if (frappe.session.user !== "Administrator") {
+ if (frm.doc.name !== "Administrator") {
frm.toggle_enable("email", frm.is_new());
}
},
diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py
index 4cfe9ca264b..2edb024016b 100644
--- a/frappe/core/doctype/user/user.py
+++ b/frappe/core/doctype/user/user.py
@@ -152,7 +152,7 @@ def validate(self):
self.password_strength_test()
if self.name not in STANDARD_USERS:
- self.validate_email_type(self.email)
+ self.email = self.name
self.validate_email_type(self.name)
self.add_system_manager_role()
self.populate_role_profile_roles()
diff --git a/frappe/core/report/database_storage_usage_by_tables/database_storage_usage_by_tables.js b/frappe/core/report/database_storage_usage_by_tables/database_storage_usage_by_tables.js
index 8a4dbefc453..5a1ae6f87ac 100644
--- a/frappe/core/report/database_storage_usage_by_tables/database_storage_usage_by_tables.js
+++ b/frappe/core/report/database_storage_usage_by_tables/database_storage_usage_by_tables.js
@@ -3,4 +3,49 @@
frappe.query_reports["Database Storage Usage By Tables"] = {
filters: [],
+ onload: function (report) {
+ report.page.add_inner_button(
+ __("Optimize"),
+ function () {
+ let d = new frappe.ui.Dialog({
+ title: "Optimize Doctype",
+ fields: [
+ {
+ label: "Select a DocType",
+ fieldname: "doctype_name",
+ fieldtype: "Link",
+ options: "DocType",
+ get_query: function () {
+ return {
+ filters: { issingle: ["=", false], is_virtual: ["=", false] },
+ };
+ },
+ },
+ ],
+ size: "small",
+ primary_action_label: "Optimize",
+ primary_action(values) {
+ frappe.call({
+ method: "frappe.core.report.database_storage_usage_by_tables.database_storage_usage_by_tables.optimize_doctype",
+ args: {
+ doctype_name: values.doctype_name,
+ },
+ callback: function (r) {
+ if (!r.exec) {
+ frappe.show_alert(
+ __(
+ `${values.doctype_name} has been added to queue for optimization`
+ )
+ );
+ }
+ },
+ });
+ d.hide();
+ },
+ });
+ d.show();
+ },
+ __("Actions")
+ );
+ },
};
diff --git a/frappe/core/report/database_storage_usage_by_tables/database_storage_usage_by_tables.py b/frappe/core/report/database_storage_usage_by_tables/database_storage_usage_by_tables.py
index c88262552e3..73052ad170e 100644
--- a/frappe/core/report/database_storage_usage_by_tables/database_storage_usage_by_tables.py
+++ b/frappe/core/report/database_storage_usage_by_tables/database_storage_usage_by_tables.py
@@ -38,3 +38,27 @@ def execute(filters=None):
as_dict=1,
)
return COLUMNS, data
+
+
+@frappe.whitelist()
+def optimize_doctype(doctype_name: str):
+ frappe.only_for("System Manager")
+ frappe.enqueue(
+ optimize_doctype_job,
+ queue="long",
+ job_id=f"optimize-{doctype_name}",
+ doctype_name=doctype_name,
+ deduplicate=True,
+ )
+
+
+def optimize_doctype_job(doctype_name: str):
+ from frappe.utils import get_table_name
+
+ doctype_table = get_table_name(doctype_name, wrap_in_backticks=True)
+ if frappe.db.db_type == "mariadb":
+ query = f"OPTIMIZE TABLE {doctype_table};"
+ else:
+ query = f"VACUUM (ANALYZE) {doctype_table};"
+
+ frappe.db.sql(query)
diff --git a/frappe/database/database.py b/frappe/database/database.py
index 39250c14ba5..920af424b2a 100644
--- a/frappe/database/database.py
+++ b/frappe/database/database.py
@@ -33,7 +33,7 @@
from frappe.query_builder.functions import Count
from frappe.utils import CallbackManager, cint, get_datetime, get_table_name, getdate, now, sbool
from frappe.utils import cast as cast_fieldtype
-from frappe.utils.deprecations import deprecation_warning
+from frappe.utils.deprecations import deprecated, deprecation_warning
if TYPE_CHECKING:
from psycopg2 import connection as PostgresConnection
@@ -1233,8 +1233,9 @@ def escape(s, percent=True):
raise NotImplementedError
@staticmethod
+ @deprecated
def is_column_missing(e):
- raise NotImplementedError
+ return frappe.db.is_missing_column(e)
def get_descendants(self, doctype, name):
"""Return descendants of the group node in tree"""
diff --git a/frappe/desk/doctype/dashboard/dashboard.py b/frappe/desk/doctype/dashboard/dashboard.py
index a40cede53e6..0155363e5bb 100644
--- a/frappe/desk/doctype/dashboard/dashboard.py
+++ b/frappe/desk/doctype/dashboard/dashboard.py
@@ -71,19 +71,17 @@ def get_permission_query_conditions(user):
if not user:
user = frappe.session.user
- if user == "Administrator":
+ if user == "Administrator" or "System Manager" in frappe.get_roles(user):
return
- roles = frappe.get_roles(user)
- if "System Manager" in roles:
- return None
-
+ module_not_set = " ifnull(`tabDashboard`.`module`, '') = '' "
allowed_modules = [
frappe.db.escape(module.get("module_name")) for module in get_modules_from_all_apps_for_user()
]
- return "`tabDashboard`.`module` in ({allowed_modules}) or `tabDashboard`.`module` is NULL".format(
- allowed_modules=",".join(allowed_modules)
- )
+ if not allowed_modules:
+ return module_not_set
+
+ return f" `tabDashboard`.`module` in ({','.join(allowed_modules)}) or {module_not_set} "
@frappe.whitelist()
diff --git a/frappe/desk/doctype/dashboard/test_dashboard.py b/frappe/desk/doctype/dashboard/test_dashboard.py
index 99aeecaee68..3d9dd1a16ad 100644
--- a/frappe/desk/doctype/dashboard/test_dashboard.py
+++ b/frappe/desk/doctype/dashboard/test_dashboard.py
@@ -1,7 +1,23 @@
# Copyright (c) 2019, Frappe Technologies and Contributors
# License: MIT. See LICENSE
+import frappe
+from frappe.config import get_modules_from_all_apps_for_user
+from frappe.core.doctype.user.test_user import test_user
from frappe.tests.utils import FrappeTestCase
class TestDashboard(FrappeTestCase):
- pass
+ def test_permission_query(self):
+ for user in ["Administrator", "test@example.com"]:
+ with self.set_user(user):
+ frappe.get_list("Dashboard")
+
+ with test_user(roles=["_Test Role"]) as user:
+ with self.set_user(user.name):
+ frappe.get_list("Dashboard")
+ with self.set_user("Administrator"):
+ all_modules = get_modules_from_all_apps_for_user("Administrator")
+ for module in all_modules:
+ user.append("block_modules", {"module": module.get("module_name")})
+ user.save()
+ frappe.get_list("Dashboard")
diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js
index 3577e9c5ec0..31fb7e51675 100644
--- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js
+++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js
@@ -529,29 +529,32 @@ frappe.ui.form.on("Dashboard Chart", {
set_parent_document_type: async function (frm) {
let document_type = frm.doc.document_type;
- let doc_is_table =
- document_type &&
- (await frappe.db.get_value("DocType", document_type, "istable")).message.istable;
-
- frm.set_df_property("parent_document_type", "hidden", !doc_is_table);
-
- if (document_type && doc_is_table) {
- let parents = await frappe.xcall(
- "frappe.desk.doctype.dashboard_chart.dashboard_chart.get_parent_doctypes",
- { child_type: document_type }
- );
+ if (!document_type) {
+ frm.set_df_property("parent_document_type", "hidden", 1);
+ return;
+ }
+ frappe.model.with_doctype(document_type, async () => {
+ let doc_is_table = frappe.get_meta(document_type).istable;
+ frm.set_df_property("parent_document_type", "hidden", !doc_is_table);
+
+ if (doc_is_table) {
+ let parents = await frappe.xcall(
+ "frappe.desk.doctype.dashboard_chart.dashboard_chart.get_parent_doctypes",
+ { child_type: document_type }
+ );
- frm.set_query("parent_document_type", function () {
- return {
- filters: {
- name: ["in", parents],
- },
- };
- });
+ frm.set_query("parent_document_type", function () {
+ return {
+ filters: {
+ name: ["in", parents],
+ },
+ };
+ });
- if (parents.length === 1) {
- frm.set_value("parent_document_type", parents[0]);
+ if (parents.length === 1) {
+ frm.set_value("parent_document_type", parents[0]);
+ }
}
- }
+ });
},
});
diff --git a/frappe/desk/doctype/system_console/system_console.json b/frappe/desk/doctype/system_console/system_console.json
index a851831909a..9573e23b526 100644
--- a/frappe/desk/doctype/system_console/system_console.json
+++ b/frappe/desk/doctype/system_console/system_console.json
@@ -29,7 +29,7 @@
],
"fields": [
{
- "description": "To print output use log(text)
",
+ "description": "To print output use print(text)
",
"fieldname": "console",
"fieldtype": "Code",
"label": "Console",
@@ -86,7 +86,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2022-04-15 14:15:58.398590",
+ "modified": "2023-11-03 13:02:00.706806",
"modified_by": "Administrator",
"module": "Desk",
"name": "System Console",
diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py
index a991b3ade43..fdba8e0373a 100644
--- a/frappe/desk/reportview.py
+++ b/frappe/desk/reportview.py
@@ -513,6 +513,16 @@ def delete_bulk(doctype, items):
if undeleted_items and len(items) != len(undeleted_items):
frappe.clear_messages()
delete_bulk(doctype, undeleted_items)
+ elif undeleted_items:
+ frappe.msgprint(
+ _("Failed to delete {0} documents: {1}").format(len(undeleted_items), ", ".join(undeleted_items)),
+ realtime=True,
+ title=_("Bulk Operation Failed"),
+ )
+ else:
+ frappe.msgprint(
+ _("Deleted all documents successfully"), realtime=True, title=_("Bulk Operation Successful")
+ )
@frappe.whitelist()
diff --git a/frappe/gettext/extractors/html_template.py b/frappe/gettext/extractors/html_template.py
index 34f51e40326..603c78e64e7 100644
--- a/frappe/gettext/extractors/html_template.py
+++ b/frappe/gettext/extractors/html_template.py
@@ -9,7 +9,7 @@ def extract(*args, **kwargs):
Reuse the babel_extract function from jinja2.ext, but handle our own implementation of `_()`.
To handle JS microtemplates, parse all code again using regex."""
fileobj = args[0] or kwargs["fileobj"]
- print(fileobj.name)
+
code = fileobj.read().decode("utf-8")
for lineno, funcname, messages, comments in babel_extract(*args, **kwargs):
diff --git a/frappe/installer.py b/frappe/installer.py
index 4eeafa46ddb..81f107dc116 100644
--- a/frappe/installer.py
+++ b/frappe/installer.py
@@ -764,9 +764,8 @@ def get_old_backup_version(sql_file_path: str) -> Version | None:
"""
header = get_db_dump_header(sql_file_path).split("\n")
if match := re.search(r"Frappe (\d+\.\d+\.\d+)", header[0]):
- backup_version = match[1]
-
- return Version(backup_version) if backup_version else None
+ return Version(match[1])
+ return None
def get_backup_version(sql_file_path: str) -> Version | None:
diff --git a/frappe/integrations/doctype/oauth_client/oauth_client.json b/frappe/integrations/doctype/oauth_client/oauth_client.json
index 4418f6b1268..c6f121151f8 100644
--- a/frappe/integrations/doctype/oauth_client/oauth_client.json
+++ b/frappe/integrations/doctype/oauth_client/oauth_client.json
@@ -9,6 +9,7 @@
"client_id",
"app_name",
"user",
+ "allowed_roles",
"cb_1",
"client_secret",
"skip_authorization",
@@ -114,10 +115,16 @@
"in_standard_filter": 1,
"label": "Response Type",
"options": "Code\nToken"
+ },
+ {
+ "fieldname": "allowed_roles",
+ "fieldtype": "Table MultiSelect",
+ "label": "Allowed Roles",
+ "options": "OAuth Client Role"
}
],
"links": [],
- "modified": "2023-07-17 07:06:35.765981",
+ "modified": "2024-04-29 12:07:07.946980",
"modified_by": "Administrator",
"module": "Integrations",
"name": "OAuth Client",
@@ -141,4 +148,4 @@
"states": [],
"title_field": "app_name",
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/frappe/integrations/doctype/oauth_client/oauth_client.py b/frappe/integrations/doctype/oauth_client/oauth_client.py
index 509aaf4698e..013cef7c913 100644
--- a/frappe/integrations/doctype/oauth_client/oauth_client.py
+++ b/frappe/integrations/doctype/oauth_client/oauth_client.py
@@ -4,6 +4,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
+from frappe.permissions import SYSTEM_USER_ROLE
class OAuthClient(Document):
@@ -13,8 +14,10 @@ class OAuthClient(Document):
from typing import TYPE_CHECKING
if TYPE_CHECKING:
+ from frappe.integrations.doctype.oauth_client_role.oauth_client_role import OAuthClientRole
from frappe.types import DF
+ allowed_roles: DF.TableMultiSelect[OAuthClientRole]
app_name: DF.Data
client_id: DF.Data | None
client_secret: DF.Data | None
@@ -32,6 +35,7 @@ def validate(self):
if not self.client_secret:
self.client_secret = frappe.generate_hash(length=10)
self.validate_grant_and_response()
+ self.add_default_role()
def validate_grant_and_response(self):
if (
@@ -45,3 +49,12 @@ def validate_grant_and_response(self):
"Combination of Grant Type ({0}
) and Response Type ({1}
) not allowed"
).format(self.grant_type, self.response_type)
)
+
+ def add_default_role(self):
+ if not self.allowed_roles:
+ self.append("allowed_roles", {"role": SYSTEM_USER_ROLE})
+
+ def user_has_allowed_role(self) -> bool:
+ """Returns true if session user is allowed to use this client."""
+ allowed_roles = {d.role for d in self.allowed_roles}
+ return bool(allowed_roles & set(frappe.get_roles()))
diff --git a/frappe/integrations/doctype/oauth_client/patches/__init__.py b/frappe/integrations/doctype/oauth_client/patches/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/frappe/integrations/doctype/oauth_client/patches/set_default_allowed_role_in_oauth_client.py b/frappe/integrations/doctype/oauth_client/patches/set_default_allowed_role_in_oauth_client.py
new file mode 100644
index 00000000000..0e877f41291
--- /dev/null
+++ b/frappe/integrations/doctype/oauth_client/patches/set_default_allowed_role_in_oauth_client.py
@@ -0,0 +1,11 @@
+import frappe
+
+
+def execute():
+ """Set default allowed role in OAuth Client"""
+ for client in frappe.get_all("OAuth Client", pluck="name"):
+ doc = frappe.get_doc("OAuth Client", client)
+ if doc.allowed_roles:
+ continue
+ row = doc.append("allowed_roles", {"role": "All"}) # Current default
+ row.db_insert()
diff --git a/frappe/integrations/doctype/oauth_client_role/__init__.py b/frappe/integrations/doctype/oauth_client_role/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/frappe/integrations/doctype/oauth_client_role/oauth_client_role.json b/frappe/integrations/doctype/oauth_client_role/oauth_client_role.json
new file mode 100644
index 00000000000..34352012d56
--- /dev/null
+++ b/frappe/integrations/doctype/oauth_client_role/oauth_client_role.json
@@ -0,0 +1,31 @@
+{
+ "actions": [],
+ "creation": "2024-04-29 12:08:19.459404",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "role"
+ ],
+ "fields": [
+ {
+ "fieldname": "role",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Role",
+ "options": "Role"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2024-04-29 12:16:48.018031",
+ "modified_by": "Administrator",
+ "module": "Integrations",
+ "name": "OAuth Client Role",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/frappe/integrations/doctype/oauth_client_role/oauth_client_role.py b/frappe/integrations/doctype/oauth_client_role/oauth_client_role.py
new file mode 100644
index 00000000000..99cd8fef49f
--- /dev/null
+++ b/frappe/integrations/doctype/oauth_client_role/oauth_client_role.py
@@ -0,0 +1,23 @@
+# Copyright (c) 2024, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class OAuthClientRole(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ role: DF.Link | None
+ # end: auto-generated types
+
+ pass
diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py
index 24c1bf32471..ccd5b29774c 100644
--- a/frappe/model/base_document.py
+++ b/frappe/model/base_document.py
@@ -561,8 +561,8 @@ def db_insert(self, ignore_if_duplicate=False):
if frappe.db.is_primary_key_violation(e):
if self.meta.autoname == "hash":
# hash collision? try again
- frappe.flags.retry_count = (frappe.flags.retry_count or 0) + 1
- if frappe.flags.retry_count > 5 and not frappe.flags.in_test:
+ self.flags.retry_count = (self.flags.retry_count or 0) + 1
+ if self.flags.retry_count > 5:
raise
self.name = None
self.db_insert()
diff --git a/frappe/model/document.py b/frappe/model/document.py
index 6ff04a9754a..3ee08327d3e 100644
--- a/frappe/model/document.py
+++ b/frappe/model/document.py
@@ -1617,6 +1617,12 @@ def add_tag(self, tag):
DocTags(self.doctype).add(self.name, tag)
+ def remove_tag(self, tag):
+ """Remove a Tag to this document"""
+ from frappe.desk.doctype.tag.tag import DocTags
+
+ DocTags(self.doctype).remove(self.name, tag)
+
def get_tags(self):
"""Return a list of Tags attached to this document"""
from frappe.desk.doctype.tag.tag import DocTags
diff --git a/frappe/model/naming.py b/frappe/model/naming.py
index 795d02f2c11..cbc7b729899 100644
--- a/frappe/model/naming.py
+++ b/frappe/model/naming.py
@@ -1,8 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
+import base64
import datetime
import re
+import time
from collections.abc import Callable
from typing import TYPE_CHECKING, Optional
@@ -262,12 +264,36 @@ def make_autoname(key="", doctype="", doc="", *, ignore_validate=False):
DE/09/01/00001 where 09 is the year, 01 is the month and 00001 is the series
"""
if key == "hash":
- return frappe.generate_hash(length=10)
+ # Makeshift "ULID": first 4 chars are based on timestamp, other 6 are random
+ return _get_timestamp_prefix() + _generate_random_string(6)
series = NamingSeries(key)
return series.generate_next_name(doc, ignore_validate=ignore_validate)
+def _get_timestamp_prefix():
+ ts = int(time.time() * 10) # time in deciseconds
+ # we ~~don't need~~ can't get ordering over entire lifetime, so we wrap the time.
+ ts = ts % (32**4)
+ return base64.b32hexencode(ts.to_bytes(length=5, byteorder="big")).decode()[-4:].lower()
+
+
+def _generate_random_string(length=10):
+ """Better version of frappe.generate_hash for naming.
+
+ This uses entire base32 instead of base16 used by generate_hash. So it has twice as many
+ characters and hence more likely to have shorter common prefixes. i.e. slighly faster comparisons and less conflicts.
+
+ Why not base36?
+ It's not in standard library else using all characters is probably better approach.
+ Why not base64?
+ MySQL is case-insensitive, we can't use both upper and lower case characters.
+ """
+ from secrets import token_bytes as get_random_bytes
+
+ return base64.b32hexencode(get_random_bytes(length)).decode()[:length].lower()
+
+
def parse_naming_series(
parts: list[str] | str,
doctype=None,
diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py
index 4f624c6a161..d996180a56a 100644
--- a/frappe/model/rename_doc.py
+++ b/frappe/model/rename_doc.py
@@ -463,7 +463,12 @@ def get_link_fields(doctype: str) -> list[dict]:
.inner_join(dt)
.on(df.parent == dt.name)
.select(df.parent, df.fieldname, dt.issingle.as_("issingle"))
- .where((df.options == doctype) & (df.fieldtype == "Link") & (dt.is_virtual == 0))
+ .where(
+ (df.options == doctype)
+ & (df.fieldtype == "Link")
+ & (df.is_virtual == 0)
+ & (dt.is_virtual == 0)
+ )
.run(as_dict=True)
)
diff --git a/frappe/oauth.py b/frappe/oauth.py
index 3ddbc0a61a9..5867bb6fc07 100644
--- a/frappe/oauth.py
+++ b/frappe/oauth.py
@@ -20,10 +20,11 @@ def validate_client_id(self, client_id, request, *args, **kwargs):
# Simple validity check, does client exist? Not banned?
cli_id = frappe.db.get_value("OAuth Client", {"name": client_id})
if cli_id:
- request.client = frappe.get_doc("OAuth Client", client_id).as_dict()
- return True
- else:
- return False
+ client = frappe.get_doc("OAuth Client", client_id)
+ if client.user_has_allowed_role():
+ request.client = client.as_dict()
+ return True
+ return False
def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs):
# Is the client allowed to use the supplied redirect_uri? i.e. has
diff --git a/frappe/patches.txt b/frappe/patches.txt
index d86f4f5f95c..cdd418a7cba 100644
--- a/frappe/patches.txt
+++ b/frappe/patches.txt
@@ -234,3 +234,4 @@ frappe.core.doctype.data_import.patches.remove_stale_docfields_from_legacy_versi
frappe.patches.v15_0.validate_newsletter_recipients
frappe.patches.v15_0.sanitize_workspace_titles
frappe.custom.doctype.property_setter.patches.remove_invalid_fetch_from_expressions
+frappe.integrations.doctype.oauth_client.patches.set_default_allowed_role_in_oauth_client
diff --git a/frappe/public/js/form_builder/components/Section.vue b/frappe/public/js/form_builder/components/Section.vue
index cd5edabe067..6f01d5eaed4 100644
--- a/frappe/public/js/form_builder/components/Section.vue
+++ b/frappe/public/js/form_builder/components/Section.vue
@@ -260,14 +260,14 @@ function delete_column(with_children) {
const options = computed(() => {
let groups = [
{
- group: "Section",
+ group: __("Section"),
items: [
{ label: __("Add section below"), onClick: add_section_below },
{ label: __("Remove section"), onClick: remove_section },
],
},
{
- group: "Column",
+ group: __("Column"),
items: [{ label: __("Add column"), onClick: add_column }],
},
];
diff --git a/frappe/public/js/form_builder/components/Sidebar.vue b/frappe/public/js/form_builder/components/Sidebar.vue
index 11c65d57a1a..c70321d316b 100644
--- a/frappe/public/js/form_builder/components/Sidebar.vue
+++ b/frappe/public/js/form_builder/components/Sidebar.vue
@@ -40,7 +40,10 @@ function resize(e) {