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