From 66212517a90f4029284c57e91aa3f3d508a15523 Mon Sep 17 00:00:00 2001 From: Maharshi Patel <39730881+maharshivpatel@users.noreply.github.com> Date: Tue, 30 Apr 2024 16:06:56 +0530 Subject: [PATCH 01/34] chore: warn if wkhtmltopdf is invalid (#26174) * chore: warn if wkhtmltopdf is invalid wkhtmltopdf ( with patched qt ) is required to generate pdfs properly. when user clicks on PDF, pdf will be generated and downloaded. however, on print preview page warning will be shown. * chore: refactor based on review comments * chore: return False incase of exception * chore: refactor and better naming Co-authored-by: Ankush Menat (cherry picked from commit 6a6ded156f4e962a58248d12e1aee0f3effaa0be) --- frappe/printing/page/print/print.js | 20 +++++++++++++++++++- frappe/utils/pdf.py | 11 +++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js index f86c0099bb6..05e6d9ee8f8 100644 --- a/frappe/printing/page/print/print.js +++ b/frappe/printing/page/print/print.js @@ -603,7 +603,24 @@ frappe.ui.form.PrintView = class { }, }); } - + async is_wkhtmltopdf_valid() { + const is_valid = await frappe.xcall("frappe.utils.pdf.is_wkhtmltopdf_valid"); + // function returns true or false + if (is_valid) return; + frappe.msgprint({ + title: __("Invalid wkhtmltopdf version"), + message: + __("PDF generation may not work as expected.") + + "
" + + __("Please contact your system manager to install correct version.") + + "
" + + __("Correct version :") + + " " + + __("wkhtmltopdf 0.12.x (with patched qt).") + + "", + indicator: "red", + }); + } render_pdf() { let print_format = this.get_print_format(); if (print_format.print_format_builder_beta) { @@ -619,6 +636,7 @@ frappe.ui.form.PrintView = class { return; } } else { + this.is_wkhtmltopdf_valid(); this.render_page("/api/method/frappe.utils.print_format.download_pdf?"); } } diff --git a/frappe/utils/pdf.py b/frappe/utils/pdf.py index 41bd4c41d12..8b20da19b37 100644 --- a/frappe/utils/pdf.py +++ b/frappe/utils/pdf.py @@ -18,6 +18,7 @@ from frappe import _ from frappe.core.doctype.file.utils import find_file_by_url from frappe.utils import cstr, scrub_urls +from frappe.utils.caching import redis_cache from frappe.utils.jinja_globals import bundled_asset, is_rtl PDF_CONTENT_ERRORS = [ @@ -352,6 +353,16 @@ def toggle_visible_pdf(soup): tag.extract() +@frappe.whitelist() +@redis_cache(ttl=60 * 60) +def is_wkhtmltopdf_valid(): + try: + output = subprocess.check_output(["wkhtmltopdf", "--version"]) + return "qt" in output.decode("utf-8").lower() + except Exception: + return False + + def get_wkhtmltopdf_version(): wkhtmltopdf_version = frappe.cache.hget("wkhtmltopdf_version", None) From 7d6a40ac4ee1fe601345b4376c18280cd522e918 Mon Sep 17 00:00:00 2001 From: Exequiel Arona Date: Tue, 30 Apr 2024 07:44:34 -0300 Subject: [PATCH 02/34] feat: workspace extraction improvements (#26169) * feat: add extractor for count format of shortcut * feat: add extraction of description string (cherry picked from commit 2263acf80cb1097f8dd651ebc2b98ca7e6e6542b) --- frappe/gettext/extractors/workspace.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/frappe/gettext/extractors/workspace.py b/frappe/gettext/extractors/workspace.py index 8aa437e2f0b..76ca108df44 100644 --- a/frappe/gettext/extractors/workspace.py +++ b/frappe/gettext/extractors/workspace.py @@ -35,6 +35,15 @@ def extract(fileobj, *args, **kwargs): ) for link in data.get("links", []) ) + yield from ( + ( + None, + "pgettext", + (link.get("link_to") if link.get("link_type") == "DocType" else None, link.get("description")), + [f"Description of a {link.get('type')} in the {workspace_name} Workspace"], + ) + for link in data.get("links", []) + ) yield from ( ( None, @@ -44,3 +53,12 @@ def extract(fileobj, *args, **kwargs): ) for shortcut in data.get("shortcuts", []) ) + yield from ( + ( + None, + "pgettext", + (shortcut.get("link_to") if shortcut.get("type") == "DocType" else None, shortcut.get("format")), + [f"Count format of shortcut in the {workspace_name} Workspace"], + ) + for shortcut in data.get("shortcuts", []) + ) From 084723df10e2880d6847eca8c28320fb62b38bbf Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 30 Apr 2024 16:42:49 +0530 Subject: [PATCH 03/34] test: flaky link field test (#26246) (cherry picked from commit c1e8d8e7919ca4cda13a76ff566fbf12163654d4) --- cypress/integration/control_link.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index 7f8123645dc..e8e02a1817e 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -27,7 +27,7 @@ context("Control Link", () => { } function get_dialog_with_gender_link() { - return cy.dialog({ + let dialog = cy.dialog({ title: "Link", fields: [ { @@ -38,6 +38,8 @@ context("Control Link", () => { }, ], }); + cy.wait(500); + return dialog; } it("should set the valid value", () => { @@ -62,6 +64,7 @@ context("Control Link", () => { cy.wait("@search_link"); cy.get("@input").type("todo for link", { delay: 200 }); cy.wait("@search_link"); + cy.wait(500); cy.get(".frappe-control[data-fieldname=link]").findByRole("listbox").should("be.visible"); cy.get(".frappe-control[data-fieldname=link] input").type("{enter}", { delay: 100 }); cy.get(".frappe-control[data-fieldname=link] input").blur(); @@ -82,6 +85,7 @@ context("Control Link", () => { .type("invalid value", { delay: 100 }) .blur(); cy.wait("@validate_link"); + cy.wait(500); cy.get(".frappe-control[data-fieldname=link] input").should("have.value", ""); }); @@ -92,6 +96,7 @@ context("Control Link", () => { cy.get(".frappe-control[data-fieldname=link] input").type(" ", { delay: 100 }).blur(); cy.wait("@validate_link"); + cy.wait(500); cy.get(".frappe-control[data-fieldname=link] input").should("have.value", ""); cy.window() .its("cur_dialog") @@ -262,6 +267,7 @@ context("Control Link", () => { cy.wait("@search_link"); cy.get("@input").type("Sonstiges", { delay: 200 }); cy.wait("@search_link"); + cy.wait(500); cy.get(".frappe-control[data-fieldname=link] ul").should("be.visible"); cy.get(".frappe-control[data-fieldname=link] input").type("{enter}", { delay: 100 }); cy.get(".frappe-control[data-fieldname=link] input").blur(); @@ -284,7 +290,7 @@ context("Control Link", () => { }); cy.clear_cache(); - cy.wait(500); + cy.wait(1000); get_dialog_with_gender_link().as("dialog"); cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link"); @@ -293,6 +299,7 @@ context("Control Link", () => { cy.wait("@search_link"); cy.get("@input").type("Non-Conforming", { delay: 200 }); cy.wait("@search_link"); + cy.wait(500); cy.get(".frappe-control[data-fieldname=link] ul").should("be.visible"); cy.get(".frappe-control[data-fieldname=link] input").type("{enter}", { delay: 100 }); cy.get(".frappe-control[data-fieldname=link] input").blur(); From 7d25aedaafde97c5b911cd8ea727d7afdf1e844e Mon Sep 17 00:00:00 2001 From: Fritz Date: Tue, 30 Apr 2024 15:00:43 +0200 Subject: [PATCH 04/34] fix: Treeview DB lookup should perform the same preperation operations as method update_nsm in file nestedset.py (#26199) --- frappe/desk/treeview.py | 4 ++-- frappe/public/js/frappe/views/treeview.js | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frappe/desk/treeview.py b/frappe/desk/treeview.py index f8b2a67c821..c37eb8b4624 100644 --- a/frappe/desk/treeview.py +++ b/frappe/desk/treeview.py @@ -42,7 +42,7 @@ def get_children(doctype, parent="", **filters): def _get_children(doctype, parent="", ignore_permissions=False): - parent_field = "parent_" + doctype.lower().replace(" ", "_") + parent_field = "parent_" + frappe.scrub(doctype) filters = [[f"ifnull(`{parent_field}`,'')", "=", parent], ["docstatus", "<", 2]] meta = frappe.get_meta(doctype) @@ -72,7 +72,7 @@ def make_tree_args(**kwarg): kwarg.pop("cmd", None) doctype = kwarg["doctype"] - parent_field = "parent_" + doctype.lower().replace(" ", "_") + parent_field = "parent_" + frappe.scrub(doctype) if kwarg["is_root"] == "false": kwarg["is_root"] = False diff --git a/frappe/public/js/frappe/views/treeview.js b/frappe/public/js/frappe/views/treeview.js index b324ce4c45c..d7f12f973fb 100644 --- a/frappe/public/js/frappe/views/treeview.js +++ b/frappe/public/js/frappe/views/treeview.js @@ -334,7 +334,8 @@ frappe.views.TreeView = class TreeView { }); var args = $.extend({}, me.args); - args["parent_" + me.doctype.toLowerCase().replace(/ /g, "_")] = me.args["parent"]; + args["parent_" + me.doctype.toLowerCase().replace(/ /g, "_").replace(/-/g, "_")] = + me.args["parent"]; d.set_value("is_group", 0); d.set_values(args); From 97355f93aaa4e219f0b751345def9a3143f5ac07 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 30 Apr 2024 19:31:33 +0530 Subject: [PATCH 05/34] fix: lstrip for query writes detection (#26180) (#26253) (cherry picked from commit c0e779998d6c6b31ee05a9c61f39eb35289d3dcd) Co-authored-by: Nahuel Operto <46027152+Don-Leopardo@users.noreply.github.com> --- frappe/database/database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index 920af424b2a..25e86b34562 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -425,7 +425,7 @@ def check_transaction_status(self, query): if query and is_query_type(query, ("commit", "rollback")): self.transaction_writes = 0 - if query[:6].lower() in ("update", "insert", "delete"): + if query.lstrip()[:6].lower() in ("update", "insert", "delete"): self.transaction_writes += 1 if self.transaction_writes > self.MAX_WRITES_PER_TRANSACTION: if self.auto_commit_on_many_writes: From 1fac55de1adb2194cabeb8b7fd8d61b071491103 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 30 Apr 2024 19:36:37 +0530 Subject: [PATCH 06/34] fix: init db conn for unbuffered cursor if not set (#26220) (#26257) * fix: init db conn for unbuffered cursor if not set * chore: check conn and not cursor --------- Co-authored-by: Ankush Menat (cherry picked from commit ba2715582b2aa09e53fa162bb3cc69026bb60048) Co-authored-by: Rutwik Hiwalkar --- frappe/database/mariadb/database.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index 7e51382992c..63d1f5001d2 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -525,6 +525,9 @@ def unbuffered_cursor(self): from pymysql.cursors import SSCursor try: + if not self._conn: + self.connect() + original_cursor = self._cursor new_cursor = self._cursor = self._conn.cursor(SSCursor) yield From 57ae4fe764893cdfb22f082b1550853423bf6823 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 30 Apr 2024 20:20:20 +0530 Subject: [PATCH 07/34] test: file uploader flaky test (#26254) (#26261) Previous window left open kills all other tests with no way to know which one (cherry picked from commit 9567efe20b9517ffa555a9abcb2030036d9e9965) Co-authored-by: Ankush Menat --- cypress/integration/file_uploader.js | 40 +++------------------------- 1 file changed, 4 insertions(+), 36 deletions(-) diff --git a/cypress/integration/file_uploader.js b/cypress/integration/file_uploader.js index e1cf91d0430..21907037c66 100644 --- a/cypress/integration/file_uploader.js +++ b/cypress/integration/file_uploader.js @@ -1,6 +1,9 @@ context("FileUploader", () => { before(() => { cy.login(); + }); + + beforeEach(() => { cy.visit("/app"); }); @@ -10,6 +13,7 @@ context("FileUploader", () => { .then((frappe) => { new frappe.ui.FileUploader(); }); + cy.wait(500); } it("upload dialog api works", () => { @@ -47,40 +51,4 @@ context("FileUploader", () => { .should("have.property", "file_name", "example.json"); cy.get(".modal:visible").should("not.exist"); }); - - it("should accept web links", () => { - open_upload_dialog(); - - cy.get_open_dialog().findByRole("button", { name: "Link" }).click(); - cy.get_open_dialog() - .findByPlaceholderText("Attach a web link") - .type("https://github.com", { delay: 100, force: true }); - cy.intercept("POST", "/api/method/upload_file").as("upload_file"); - cy.get_open_dialog().findByRole("button", { name: "Upload" }).click(); - cy.wait("@upload_file") - .its("response.body.message") - .should("have.property", "file_url", "https://github.com"); - cy.get(".modal:visible").should("not.exist"); - }); - - it("should allow cropping and optimization for valid images", () => { - open_upload_dialog(); - - cy.get_open_dialog() - .find(".file-upload-area") - .selectFile("cypress/fixtures/sample_image.jpg", { - action: "drag-drop", - }); - - cy.get_open_dialog().findAllByText("sample_image.jpg").should("exist"); - cy.get_open_dialog().find(".btn-crop").first().click(); - cy.get_open_dialog().findByRole("button", { name: "Crop" }).click(); - cy.get_open_dialog().findAllByRole("checkbox", { name: "Optimize" }).should("exist"); - cy.get_open_dialog().findAllByLabelText("Optimize").first().click(); - - cy.intercept("POST", "/api/method/upload_file").as("upload_file"); - cy.get_open_dialog().findByRole("button", { name: "Upload" }).click(); - cy.wait("@upload_file").its("response.statusCode").should("eq", 200); - cy.get(".modal:visible").should("not.exist"); - }); }); From 9ce789eff600f6bd1cd36d0ac24750749c577c6b Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 30 Apr 2024 19:37:27 +0200 Subject: [PATCH 08/34] fix(Data Import): scheduler not needed in dev mode (backport #24667) (#26265) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- frappe/core/doctype/data_import/data_import.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/data_import/data_import.py b/frappe/core/doctype/data_import/data_import.py index 0011b26acb0..c1b241c5aeb 100644 --- a/frappe/core/doctype/data_import/data_import.py +++ b/frappe/core/doctype/data_import/data_import.py @@ -92,7 +92,8 @@ def get_preview_from_template(self, import_file=None, google_sheets_url=None): def start_import(self): from frappe.utils.scheduler import is_scheduler_inactive - if is_scheduler_inactive() and not frappe.flags.in_test: + run_now = frappe.flags.in_test or frappe.conf.developer_mode + if is_scheduler_inactive() and not run_now: frappe.throw(_("Scheduler is inactive. Cannot import data."), title=_("Scheduler Inactive")) job_id = f"data_import::{self.name}" @@ -105,7 +106,7 @@ def start_import(self): event="data_import", job_id=job_id, data_import=self.name, - now=frappe.conf.developer_mode or frappe.flags.in_test, + now=run_now, ) return True From 113de20631f1b74045d6dce41fb2ccfad4e83a2f Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 1 May 2024 11:14:48 +0530 Subject: [PATCH 09/34] fix(Data Import): don't rely on permission for Data Import Log (backport #26228) (#26251) * fix(Data Import): don't rely on permission for Data Import Log (#26228) (cherry picked from commit 774f5cc1c6b4e289b238d2d7dd099a133b6319ea) # Conflicts: # frappe/core/doctype/data_import_log/data_import_log.json * chore: resolve conflicts * fix(Data Import): don't attempt to show logs for unsaved import --------- Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- frappe/core/doctype/data_import/data_import.js | 12 +++--------- frappe/core/doctype/data_import/data_import.py | 14 ++++++++++++++ .../doctype/data_import_log/data_import_log.json | 9 +++++---- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/frappe/core/doctype/data_import/data_import.js b/frappe/core/doctype/data_import/data_import.js index a93671e3e33..e3ebfc3356b 100644 --- a/frappe/core/doctype/data_import/data_import.js +++ b/frappe/core/doctype/data_import/data_import.js @@ -409,15 +409,9 @@ frappe.ui.form.on("Data Import", { render_import_log(frm) { frappe.call({ - method: "frappe.client.get_list", + method: "frappe.core.doctype.data_import.data_import.get_import_logs", args: { - doctype: "Data Import Log", - filters: { - data_import: frm.doc.name, - }, - fields: ["success", "docname", "messages", "exception", "row_indexes"], - limit_page_length: 5000, - order_by: "log_index", + data_import: frm.doc.name, }, callback: function (r) { let logs = r.message; @@ -508,7 +502,7 @@ frappe.ui.form.on("Data Import", { show_import_log(frm) { frm.toggle_display("import_log_section", false); - if (frm.import_in_progress) { + if (frm.is_new() || frm.import_in_progress) { return; } diff --git a/frappe/core/doctype/data_import/data_import.py b/frappe/core/doctype/data_import/data_import.py index c1b241c5aeb..0e6adfb76b1 100644 --- a/frappe/core/doctype/data_import/data_import.py +++ b/frappe/core/doctype/data_import/data_import.py @@ -218,6 +218,20 @@ def get_import_status(data_import_name): return import_status +@frappe.whitelist() +def get_import_logs(data_import: str): + doc = frappe.get_doc("Data Import", data_import) + doc.check_permission("read") + + return frappe.get_all( + "Data Import Log", + fields=["success", "docname", "messages", "exception", "row_indexes"], + filters={"data_import": data_import}, + limit_page_length=5000, + order_by="log_index", + ) + + def import_file(doctype, file_path, import_type, submit_after_import=False, console=False): """ Import documents in from CSV or XLSX using data import. diff --git a/frappe/core/doctype/data_import_log/data_import_log.json b/frappe/core/doctype/data_import_log/data_import_log.json index b1d991f0997..c184df193da 100644 --- a/frappe/core/doctype/data_import_log/data_import_log.json +++ b/frappe/core/doctype/data_import_log/data_import_log.json @@ -58,9 +58,8 @@ } ], "in_create": 1, - "index_web_pages_for_search": 1, "links": [], - "modified": "2021-12-29 11:19:19.646076", + "modified": "2024-04-29 18:44:17.050909", "modified_by": "Administrator", "module": "Core", "name": "Data Import Log", @@ -79,6 +78,8 @@ "write": 1 } ], - "sort_field": "modified", - "sort_order": "DESC" + "read_only": 1, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] } \ No newline at end of file From e3f15d0ed0cda3be58934c2ac8eaa1fcf0d593fa Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 1 May 2024 18:26:26 +0530 Subject: [PATCH 10/34] fix(oauth2): refresh token is optional (#26266) (#26272) Don't overwrite refresh_token with an empty string, if no new refresh_token is received (i.e. the old one is still valid). Ref: https://www.rfc-editor.org/rfc/rfc6749#section-5.1 (cherry picked from commit 42be1455f8d035de07e88b844e9f258769f36bab) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- frappe/integrations/doctype/token_cache/token_cache.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/integrations/doctype/token_cache/token_cache.py b/frappe/integrations/doctype/token_cache/token_cache.py index b349b769db1..9e9b729350e 100644 --- a/frappe/integrations/doctype/token_cache/token_cache.py +++ b/frappe/integrations/doctype/token_cache/token_cache.py @@ -53,9 +53,11 @@ def update_data(self, data): self.token_type = token_type self.access_token = cstr(data.get("access_token", "")) - self.refresh_token = cstr(data.get("refresh_token", "")) self.expires_in = cint(data.get("expires_in", 0)) + if "refresh_token" in data: + self.refresh_token = cstr(data.get("refresh_token")) + new_scopes = data.get("scope") if new_scopes: if isinstance(new_scopes, str): From 20b364320325601fc0b7c8e848b5b3012af8ff84 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 1 May 2024 19:05:18 +0530 Subject: [PATCH 11/34] fix(Navbar Settings): reload page after save (#26274) (#26276) * fix(Navbar Settings): reload page after save * test: file uploader flake --------- Co-authored-by: Ankush Menat (cherry picked from commit 8eb8c64fbd20f8bb3e583a6c1208b63ca382ddea) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- cypress/integration/file_uploader.js | 1 + frappe/core/doctype/navbar_settings/navbar_settings.js | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/cypress/integration/file_uploader.js b/cypress/integration/file_uploader.js index 21907037c66..9905bc7461f 100644 --- a/cypress/integration/file_uploader.js +++ b/cypress/integration/file_uploader.js @@ -5,6 +5,7 @@ context("FileUploader", () => { beforeEach(() => { cy.visit("/app"); + cy.wait(2000); // workspace can load async and clear active dialog }); function open_upload_dialog() { diff --git a/frappe/core/doctype/navbar_settings/navbar_settings.js b/frappe/core/doctype/navbar_settings/navbar_settings.js index ed7e3319867..327576114ec 100644 --- a/frappe/core/doctype/navbar_settings/navbar_settings.js +++ b/frappe/core/doctype/navbar_settings/navbar_settings.js @@ -1,4 +1,8 @@ // Copyright (c) 2020, Frappe Technologies and contributors // For license information, please see license.txt -frappe.ui.form.on("Navbar Settings", {}); +frappe.ui.form.on("Navbar Settings", { + after_save: function (frm) { + frappe.ui.toolbar.clear_cache(); + }, +}); From 9dec74be95a08b420dc0624f92315d1ca4be7875 Mon Sep 17 00:00:00 2001 From: gparent <370905+gparent@users.noreply.github.com> Date: Mon, 29 Apr 2024 16:30:29 +0000 Subject: [PATCH 12/34] fix(Geo): change Canadian dates to ISO 8601 format (cherry picked from commit 902d48ce865acf7b9aaf2051c7b707e5da5c1c01) --- frappe/geo/country_info.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/geo/country_info.json b/frappe/geo/country_info.json index 8ce03b94056..1e4d4fa443e 100644 --- a/frappe/geo/country_info.json +++ b/frappe/geo/country_info.json @@ -497,7 +497,7 @@ "currency_fraction_units": 100, "currency_name": "Canadian Dollar", "currency_symbol": "$", - "date_format": "mm-dd-yyyy", + "date_format": "yyyy-mm-dd", "number_format": "#,###.##", "timezones": [ "America/Atikokan", From 11f041b17908daa443d969ad68eec78d40f43ec9 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 2 May 2024 12:36:44 +0530 Subject: [PATCH 13/34] fix: reportview average of ints should be float (#26284) (#26288) (cherry picked from commit 8bd40b38e15ffcd22c604b6aef51e13ecf979d7d) Co-authored-by: Ankush Menat --- frappe/public/js/frappe/ui/group_by/group_by.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frappe/public/js/frappe/ui/group_by/group_by.js b/frappe/public/js/frappe/ui/group_by/group_by.js index 1fd90ca4e3d..349a7140d5b 100644 --- a/frappe/public/js/frappe/ui/group_by/group_by.js +++ b/frappe/public/js/frappe/ui/group_by/group_by.js @@ -327,6 +327,9 @@ frappe.ui.GroupBy = class { if (this.aggregate_function === "sum") { docfield.label = __("Sum of {0}", [__(docfield.label, null, docfield.parent)]); } else { + if (docfield.fieldtype == "Int") { + docfield.fieldtype = "Float"; // average of ints can be a float + } docfield.label = __("Average of {0}", [__(docfield.label, null, docfield.parent)]); } } From 889eb9a3835f1874720ce3b65568adc458dc103f Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 2 May 2024 13:36:40 +0530 Subject: [PATCH 14/34] fix: misc sys health report fixes (backport #26262) (#26290) * fix: system health ergonomics - if redis is down it takes 10 second and doesn't indicate that correctly - full traceback is shared in debug log, if some step fails (cherry picked from commit b5bc8b308db9b5b682c199c5e1d1609fdd7fe96b) * refactor: avoid deprecated method (cherry picked from commit 1a3c23290fb5e18d34f1e7a12879b7c974ab4a8e) --------- Co-authored-by: Ankush Menat --- frappe/core/doctype/comment/comment.py | 2 +- .../system_health_report.py | 25 +++++++++++++++++-- frappe/desk/doctype/tag/tag.py | 4 +-- frappe/desk/doctype/todo/todo.py | 2 +- frappe/desk/like.py | 2 +- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/frappe/core/doctype/comment/comment.py b/frappe/core/doctype/comment/comment.py index 42f5c4b8cc0..127f5f322dd 100644 --- a/frappe/core/doctype/comment/comment.py +++ b/frappe/core/doctype/comment/comment.py @@ -192,7 +192,7 @@ def update_comments_in_parent(reference_doctype, reference_name, _comments): ) except Exception as e: - if frappe.db.is_column_missing(e) and getattr(frappe.local, "request", None): + if frappe.db.is_missing_column(e) and getattr(frappe.local, "request", None): pass elif frappe.db.is_data_too_long(e): raise frappe.DataTooLongException diff --git a/frappe/desk/doctype/system_health_report/system_health_report.py b/frappe/desk/doctype/system_health_report/system_health_report.py index e5a3a85b8ae..be8f0714b68 100644 --- a/frappe/desk/doctype/system_health_report/system_health_report.py +++ b/frappe/desk/doctype/system_health_report/system_health_report.py @@ -23,15 +23,29 @@ import os from collections import defaultdict from collections.abc import Callable +from contextlib import contextmanager import frappe from frappe.model.document import Document -from frappe.utils.background_jobs import get_queue, get_queue_list +from frappe.utils.background_jobs import get_queue, get_queue_list, get_redis_conn from frappe.utils.caching import redis_cache from frappe.utils.data import add_to_date from frappe.utils.scheduler import get_scheduler_status +@contextmanager +def no_wait(func): + "Disable tenacity waiting on some function" + from tenacity import stop_after_attempt + + try: + original_stop = func.retry.stop + func.retry.stop = stop_after_attempt(1) + yield + finally: + func.retry.stop = original_stop + + def health_check(step: str): assert isinstance(step, str), "Invalid usage of decorator, Usage: @health_check('step name')" @@ -41,8 +55,11 @@ def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except Exception as e: + frappe.log(frappe.get_traceback()) # nosemgrep - frappe.msgprint(f"System Health check step {frappe.bold(step)} failed: {e}", alert=True) + frappe.msgprint( + f"System Health check step {frappe.bold(step)} failed: {e}", alert=True, indicator="red" + ) return wrapper @@ -130,7 +147,10 @@ def load_from_db(self): self.fetch_user_stats() @health_check("Background Jobs") + @no_wait(get_redis_conn) def fetch_background_jobs(self): + self.background_jobs_check = "failed" + # This just checks connection life self.test_job_id = frappe.enqueue("frappe.ping", at_front=True).id self.background_jobs_check = "queued" self.scheduler_status = get_scheduler_status().get("status") @@ -296,6 +316,7 @@ def get_stats(**kwargs): @frappe.whitelist() +@no_wait(get_redis_conn) def get_job_status(job_id: str | None = None): frappe.only_for("System Manager") try: diff --git a/frappe/desk/doctype/tag/tag.py b/frappe/desk/doctype/tag/tag.py index 6b47f490b2d..a416c13bf0e 100644 --- a/frappe/desk/doctype/tag/tag.py +++ b/frappe/desk/doctype/tag/tag.py @@ -27,7 +27,7 @@ def check_user_tags(dt): doctype = DocType(dt) frappe.qb.from_(doctype).select(doctype._user_tags).limit(1).run() except Exception as e: - if frappe.db.is_column_missing(e): + if frappe.db.is_missing_column(e): DocTags(dt).setup() @@ -117,7 +117,7 @@ def update(self, dn, tl): doc = frappe.get_doc(self.dt, dn) update_tags(doc, tags) except Exception as e: - if frappe.db.is_column_missing(e): + if frappe.db.is_missing_column(e): if not tags: # no tags, nothing to do return diff --git a/frappe/desk/doctype/todo/todo.py b/frappe/desk/doctype/todo/todo.py index 83d9332f76a..b82384b732d 100644 --- a/frappe/desk/doctype/todo/todo.py +++ b/frappe/desk/doctype/todo/todo.py @@ -122,7 +122,7 @@ def update_in_reference(self): # no table return - elif frappe.db.is_column_missing(e): + elif frappe.db.is_missing_column(e): from frappe.database.schema import add_column add_column(self.reference_type, "_assign", "Text") diff --git a/frappe/desk/like.py b/frappe/desk/like.py index 2be2362b2de..42211087f45 100644 --- a/frappe/desk/like.py +++ b/frappe/desk/like.py @@ -58,7 +58,7 @@ def _toggle_like(doctype, name, add, user=None): frappe.db.set_value(doctype, name, "_liked_by", json.dumps(liked_by), update_modified=False) except frappe.db.ProgrammingError as e: - if frappe.db.is_column_missing(e): + if frappe.db.is_missing_column(e): add_column(doctype, "_liked_by", "Text") _toggle_like(doctype, name, add, user) else: From f183a3110f812624c751cd343b88f30f3e2a1100 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 2 May 2024 14:23:19 +0530 Subject: [PATCH 15/34] fix: don't add creation index if one exists (#26295) --- frappe/database/mariadb/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/database/mariadb/schema.py b/frappe/database/mariadb/schema.py index c16c1496ff2..1af18caeb47 100644 --- a/frappe/database/mariadb/schema.py +++ b/frappe/database/mariadb/schema.py @@ -81,7 +81,7 @@ def alter(self): if not frappe.db.get_column_index(self.table_name, col.fieldname, unique=False) ] - if self.meta.sort_field == "creation" and frappe.db.get_column_index( + if self.meta.sort_field == "creation" and not frappe.db.get_column_index( self.table_name, "creation", unique=False ): add_index_query.append("ADD INDEX `creation`(`creation`)") From d72c6eccfeb35c02c5933d014946f577ebc3317d Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 2 May 2024 09:11:06 +0000 Subject: [PATCH 16/34] fix: changes for scheduler reliability (backport #26292) (#26294) * fix: Dont let one invalid cron fail scheduler Scenario: - One bad cron job exists - When it fails nothing after that job is enqueued. After this fix, that failure is skipped and rest of the jobs are enqueued. (cherry picked from commit b0aaeb50969ff26954b275f81a7e2beca204dfea) # Conflicts: # frappe/utils/scheduler.py * fix: update last_execution for no-log unconditionally Cron jobs can now also disable logging. (cherry picked from commit 86b1e9ec31581ea8722c5dc732d7070bf12a2b55) * feat: show oldest unscheduled job in health report (cherry picked from commit b2ef2cd506c9645e637928b1d353f8f4f01f1c66) * Update frappe/utils/scheduler.py --------- Co-authored-by: Ankush Menat --- .../scheduled_job_type/scheduled_job_type.py | 7 +++---- .../system_health_report.js | 6 +++++- .../system_health_report.json | 20 ++++++++++++++++++- .../system_health_report.py | 16 ++++++++++++++- frappe/utils/scheduler.py | 17 +++++++++++++--- 5 files changed, 56 insertions(+), 10 deletions(-) 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 3fb251362ef..8072b58222b 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -117,7 +117,7 @@ def get_next_execution(self): } if not self.cron_format: - self.cron_format = CRON_MAP[self.frequency] + self.cron_format = CRON_MAP.get(self.frequency) # If this is a cold start then last_execution will not be set. # Creation is set as fallback because if very old fallback is set job might trigger @@ -155,9 +155,8 @@ def log_status(self, status): def update_scheduler_log(self, status): if not self.create_log: # self.get_next_execution will work properly iff self.last_execution is properly set - if self.frequency == "All" and status == "Start": - self.db_set("last_execution", now_datetime(), update_modified=False) - frappe.db.commit() + self.db_set("last_execution", now_datetime(), update_modified=False) + frappe.db.commit() return if not self.scheduler_log: self.scheduler_log = frappe.get_doc( diff --git a/frappe/desk/doctype/system_health_report/system_health_report.js b/frappe/desk/doctype/system_health_report/system_health_report.js index a57c9076a55..fcf5d522893 100644 --- a/frappe/desk/doctype/system_health_report/system_health_report.js +++ b/frappe/desk/doctype/system_health_report/system_health_report.js @@ -56,6 +56,7 @@ frappe.ui.form.on("System Health Report", { val > 3 && frm.doc.total_outgoing_emails > 3 && val / frm.doc.total_outgoing_emails > 0.1, + oldest_unscheduled_job: (val) => !!val, "queue_status.pending_jobs": (val) => val > 50, "background_workers.utilization": (val) => val > 70, "background_workers.failed_jobs": (val) => val > 50, @@ -72,6 +73,9 @@ frappe.ui.form.on("System Health Report", { document.head.appendChild(style); const update_fields = () => { + if (!frappe.get_route().includes(frm.doc.name)) { + clearInterval(interval); + } Object.entries(conditions).forEach(([field, condition]) => { try { if (field.includes(".")) { @@ -93,6 +97,6 @@ frappe.ui.form.on("System Health Report", { }; update_fields(); - setInterval(update_fields, 1000); + const interval = setInterval(update_fields, 1000); }, }); diff --git a/frappe/desk/doctype/system_health_report/system_health_report.json b/frappe/desk/doctype/system_health_report/system_health_report.json index 038f7369467..4a7352ba889 100644 --- a/frappe/desk/doctype/system_health_report/system_health_report.json +++ b/frappe/desk/doctype/system_health_report/system_health_report.json @@ -17,6 +17,9 @@ "background_workers", "scheduler_section", "scheduler_status", + "column_break_bxog", + "oldest_unscheduled_job", + "section_break_vpuw", "failing_scheduled_jobs", "database_section", "database", @@ -368,6 +371,7 @@ { "fieldname": "scheduler_section", "fieldtype": "Section Break", + "hide_border": 1, "label": "Scheduler" }, { @@ -375,6 +379,20 @@ "fieldtype": "Table", "label": "Failing Scheduled Jobs (last 7 days)", "options": "System Health Report Failing Jobs" + }, + { + "fieldname": "column_break_bxog", + "fieldtype": "Column Break" + }, + { + "fieldname": "oldest_unscheduled_job", + "fieldtype": "Link", + "label": "Oldest Unscheduled Job", + "options": "Scheduled Job Type" + }, + { + "fieldname": "section_break_vpuw", + "fieldtype": "Section Break" } ], "hide_toolbar": 1, @@ -382,7 +400,7 @@ "is_virtual": 1, "issingle": 1, "links": [], - "modified": "2024-04-22 11:47:52.194784", + "modified": "2024-05-02 13:32:16.495750", "modified_by": "Administrator", "module": "Desk", "name": "System Health Report", diff --git a/frappe/desk/doctype/system_health_report/system_health_report.py b/frappe/desk/doctype/system_health_report/system_health_report.py index be8f0714b68..c3e52afa3d5 100644 --- a/frappe/desk/doctype/system_health_report/system_health_report.py +++ b/frappe/desk/doctype/system_health_report/system_health_report.py @@ -26,11 +26,12 @@ from contextlib import contextmanager import frappe +from frappe.core.doctype.scheduled_job_type.scheduled_job_type import ScheduledJobType from frappe.model.document import Document from frappe.utils.background_jobs import get_queue, get_queue_list, get_redis_conn from frappe.utils.caching import redis_cache from frappe.utils.data import add_to_date -from frappe.utils.scheduler import get_scheduler_status +from frappe.utils.scheduler import get_scheduler_status, get_scheduler_tick @contextmanager @@ -107,6 +108,7 @@ class SystemHealthReport(Document): handled_emails: DF.Int last_10_active_users: DF.Code | None new_users: DF.Int + oldest_unscheduled_job: DF.Link | None onsite_backups: DF.Int pending_emails: DF.Int private_files_size: DF.Float @@ -208,6 +210,18 @@ def fetch_scheduler(self): for job in failing_jobs: self.append("failing_scheduled_jobs", job) + threshold = add_to_date(None, seconds=-30 * get_scheduler_tick(), as_datetime=True) + for job_type in frappe.get_all( + "Scheduled Job Type", + filters={"stopped": 0, "last_execution": ("<", threshold)}, + fields="*", + order_by="last_execution asc", + ): + job_type: ScheduledJobType = frappe.get_doc(doctype="Scheduled Job Type", **job_type) + if job_type.is_event_due(): + self.oldest_unscheduled_job = job_type.name + break + @health_check("Emails") def fetch_email_stats(self): threshold = add_to_date(None, days=-7, as_datetime=True) diff --git a/frappe/utils/scheduler.py b/frappe/utils/scheduler.py index 1ab0128f97d..a3b3dae1dfd 100755 --- a/frappe/utils/scheduler.py +++ b/frappe/utils/scheduler.py @@ -14,6 +14,8 @@ import time from typing import NoReturn +from croniter import CroniterBadCronError + # imports - module imports import frappe from frappe.utils import cint, get_datetime, get_sites, now_datetime @@ -35,7 +37,7 @@ def start_scheduler() -> NoReturn: """Run enqueue_events_for_all_sites based on scheduler tick. Specify scheduler_interval in seconds in common_site_config.json""" - tick = cint(frappe.get_conf().scheduler_tick_interval) or 60 + tick = get_scheduler_tick() set_niceness() while True: @@ -90,8 +92,13 @@ def enqueue_events(site: str) -> list[str] | None: enqueued_jobs = [] for job_type in frappe.get_all("Scheduled Job Type", filters={"stopped": 0}, fields="*"): job_type = frappe.get_doc(doctype="Scheduled Job Type", **job_type) - if job_type.enqueue(): - enqueued_jobs.append(job_type.method) + try: + if job_type.enqueue(): + enqueued_jobs.append(job_type.method) + except CroniterBadCronError: + frappe.logger("scheduler").error( + f"Invalid Job on {frappe.local.site} - {job_type.name}", exc_info=True + ) return enqueued_jobs @@ -196,3 +203,7 @@ def get_scheduler_status(): if is_scheduler_inactive(): return {"status": "inactive"} return {"status": "active"} + + +def get_scheduler_tick() -> int: + return cint(frappe.get_conf().scheduler_tick_interval) or 60 From b2950d252b7b132de7f8d9fec6e7ff97482b869f Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Wed, 10 Apr 2024 16:00:38 +0530 Subject: [PATCH 17/34] fix: allow accessing reports without roles Signed-off-by: Akhil Narang (cherry picked from commit 26012aceb579797830ca8007104ccb26b1a6f94e) Signed-off-by: Akhil Narang --- frappe/boot.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/frappe/boot.py b/frappe/boot.py index 2befb0937b4..da13a110f98 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -164,7 +164,9 @@ def get_user_pages_or_reports(parent, cache=False): page = DocType("Page") report = DocType("Report") - if parent == "Report": + is_report = parent == "Report" + + if is_report: columns = (report.name.as_("title"), report.ref_doctype, report.report_type) else: columns = (page.title.as_("title"),) @@ -206,7 +208,7 @@ def get_user_pages_or_reports(parent, cache=False): .distinct() ) - if parent == "Report": + if is_report: pages_with_standard_roles = pages_with_standard_roles.where(report.disabled == 0) pages_with_standard_roles = pages_with_standard_roles.run(as_dict=True) @@ -221,19 +223,20 @@ def get_user_pages_or_reports(parent, cache=False): frappe.qb.from_(hasRole).select(Count("*")).where(hasRole.parent == parentTable.name) ) - # pages with no role are allowed - if parent == "Page": - pages_with_no_roles = ( - frappe.qb.from_(parentTable) - .select(parentTable.name, parentTable.modified, *columns) - .where(no_of_roles == 0) - ).run(as_dict=True) + # pages and reports with no role are allowed + rows_with_no_roles = ( + frappe.qb.from_(parentTable) + .select(parentTable.name, parentTable.modified, *columns) + .where(no_of_roles == 0) + ).run(as_dict=True) - for p in pages_with_no_roles: - if p.name not in has_role: - has_role[p.name] = {"modified": p.modified, "title": p.title} + for r in rows_with_no_roles: + if r.name not in has_role: + has_role[r.name] = {"modified": r.modified, "title": r.title} + if is_report: + has_role[r.name] |= {"ref_doctype": r.ref_doctype} - elif parent == "Report": + if is_report: if not has_permission("Report", raise_exception=False): return {} From 475189c7dee66136023e519819200ffb9158186d Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 2 May 2024 13:11:42 +0000 Subject: [PATCH 18/34] fix: only redirect to same domain (#26304) (#26306) This limits post login redirects to same domain to avoid social engineering attempts. (cherry picked from commit 65b3c42635038cdff17d3109be6c373bac004829) Co-authored-by: Ankush Menat --- frappe/tests/test_api.py | 15 ++++++++++++++- frappe/www/login.py | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/frappe/tests/test_api.py b/frappe/tests/test_api.py index 165ee8123f4..ad4e757e1dd 100644 --- a/frappe/tests/test_api.py +++ b/frappe/tests/test_api.py @@ -7,7 +7,7 @@ from threading import Thread from time import time from unittest.mock import patch -from urllib.parse import urljoin +from urllib.parse import urlencode, urljoin import requests from filetype import guess_mime @@ -454,6 +454,19 @@ def test_download_private_file_with_unique_url(self): self.assertEqual(self.get(file.unique_url, {"sid": self.sid}).text, test_content) self.assertEqual(self.get(file.file_url, {"sid": self.sid}).text, test_content) + def test_login_redirects(self): + expected_redirects = { + "/app/user": "/app/user", + "/app/user?enabled=1": "/app/user?enabled=1", + "http://example.com": "/app", # No external redirect + "https://google.com": "/app", + "http://localhost:8000": "/app", + "http://localhost/app": "http://localhost/app", + } + for redirect, expected_redirect in expected_redirects.items(): + response = self.get(f"/login?{urlencode({'redirect-to':redirect})}", {"sid": self.sid}) + self.assertEqual(response.location, expected_redirect) + def generate_admin_keys(): from frappe.core.doctype.user.user import generate_keys diff --git a/frappe/www/login.py b/frappe/www/login.py index c988efd2f46..4182496c0cc 100644 --- a/frappe/www/login.py +++ b/frappe/www/login.py @@ -1,6 +1,9 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE + +from urllib.parse import urlparse + import frappe import frappe.utils from frappe import _ @@ -19,6 +22,7 @@ def get_context(context): redirect_to = frappe.local.request.args.get("redirect-to") + redirect_to = sanitize_redirect(redirect_to) if frappe.session.user != "Guest": if not redirect_to: @@ -180,3 +184,24 @@ def login_via_key(key: str): http_status_code=403, indicator_color="red", ) + + +def sanitize_redirect(redirect: str | None) -> str | None: + """Only allow redirect on same domain. + + Allowed redirects: + - Same host e.g. https://frappe.localhost/path + - Just path e.g. /app + """ + if not redirect: + return redirect + + parsed_redirect = urlparse(redirect) + if not parsed_redirect.netloc: + return redirect + + parsed_request_host = urlparse(frappe.local.request.url) + if parsed_request_host.netloc == parsed_redirect.netloc: + return redirect + + return None From 5f25ae6d63912c9d77bd5e7ed0cc4eaba0cc7838 Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Fri, 3 May 2024 17:21:16 +0530 Subject: [PATCH 19/34] fix: args is a stringified JSON Signed-off-by: Akhil Narang --- frappe/model/document.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/model/document.py b/frappe/model/document.py index 3ee08327d3e..152cead59e9 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -1737,8 +1737,9 @@ def _document_values_generator( @frappe.whitelist() def unlock_document(doctype: str | None = None, name: str | None = None, args=None): + # Backward compatibility if not doctype and not name and args: - # Backward compatibility + args = json.loads(args) doctype = str(args["doctype"]) name = str(args["name"]) frappe.get_doc(doctype, name).unlock() From 9a151914652a843576f0edd38b300a67d78bd4b7 Mon Sep 17 00:00:00 2001 From: RitvikSardana <65544983+RitvikSardana@users.noreply.github.com> Date: Fri, 3 May 2024 18:34:54 +0530 Subject: [PATCH 20/34] feat: link field filter backport v15 (#25966) * feat: Apply Filters to Link Fields Via Form Builder (#22844) Co-authored-by: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> * fix: link field shown only for Link fieldtype * fix: add back if condition * feat: add eval support for link field filters * fix: support for adding filters for child table * fix: eval support for child table * chore: code cleanup * chore: code cleanup * fix: convert to string when operator is of type 'like' * style: wspace --------- Co-authored-by: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> Co-authored-by: Ankush Menat --- cypress/fixtures/form_builder_doctype.js | 6 + cypress/integration/form_builder.js | 34 +++++ frappe/core/doctype/docfield/docfield.json | 6 + frappe/core/doctype/docfield/docfield.py | 1 + frappe/core/doctype/doctype/doctype.py | 1 + .../doctype/custom_field/custom_field.json | 7 + .../doctype/custom_field/custom_field.py | 1 + .../customize_form/customize_form.json | 7 + .../doctype/customize_form/customize_form.py | 13 ++ .../customize_form_field.json | 6 + .../customize_form_field.py | 1 + .../js/form_builder/components/Field.vue | 134 ++++++++++++++++-- .../components/FieldProperties.vue | 6 +- frappe/public/js/form_builder/store.js | 16 +++ frappe/public/js/frappe/form/controls/link.js | 40 ++++++ .../js/frappe/ui/filters/filter_list.js | 3 +- 16 files changed, 268 insertions(+), 14 deletions(-) diff --git a/cypress/fixtures/form_builder_doctype.js b/cypress/fixtures/form_builder_doctype.js index 08b598f82a3..995971bed47 100644 --- a/cypress/fixtures/form_builder_doctype.js +++ b/cypress/fixtures/form_builder_doctype.js @@ -10,6 +10,12 @@ export default { fieldtype: "Data", label: "Data 3", }, + { + fieldname: "gender", + fieldtype: "Link", + label: "Gender", + options: "Gender", + }, { fieldname: "tab", fieldtype: "Tab Break", diff --git a/cypress/integration/form_builder.js b/cypress/integration/form_builder.js index 817a677f82b..68ceaf34f55 100644 --- a/cypress/integration/form_builder.js +++ b/cypress/integration/form_builder.js @@ -35,6 +35,40 @@ context("Form Builder", () => { cy.get(".title-area .indicator-pill.orange").should("have.text", "Not Saved"); }); + it("Check if Filters are applied to the link field", () => { + // Visit the Form Builder + cy.visit(`/app/doctype/${doctype_name}`); + cy.findByRole("tab", { name: "Form" }).click(); + + cy.get("[data-fieldname='gender']").click(); + + // click on filter action button + cy.get('[data-fieldname="gender"] .field-actions button:first').click(); + + // add filter + cy.get(".modal-body .clear-filters").click(); + cy.get(".modal-body .filter-action-buttons .add-filter").click(); + cy.wait(100); + cy.get(".modal-body .filter-box .list_filter .filter-field .link-field input").type( + "Male" + ); + cy.get(".btn-modal-primary").click(); + + // Save the document + cy.click_doc_primary_button("Save"); + + // Open a new Form + cy.new_form(doctype_name); + // Click on the "salutation" field + cy.get_field("gender").clear().click(); + + cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link"); + cy.wait("@search_link").then((data) => { + expect(data.response.body.message.length).to.eq(1); + expect(data.response.body.message[0].value).to.eq("Male"); + }); + }); + it("Add empty section and save", () => { cy.visit(`/app/doctype/${doctype_name}`); cy.findByRole("tab", { name: "Form" }).click(); diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index f94a51a0d49..049a56d3d85 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -23,6 +23,7 @@ "options", "sort_options", "show_dashboard", + "link_filters", "defaults_section", "default", "column_break_6", @@ -561,6 +562,11 @@ "fieldname": "sort_options", "fieldtype": "Check", "label": "Sort Options" + }, + { + "fieldname": "link_filters", + "fieldtype": "JSON", + "label": "Link Filters" } ], "idx": 1, diff --git a/frappe/core/doctype/docfield/docfield.py b/frappe/core/doctype/docfield/docfield.py index 327395661e9..6fe75ade8ff 100644 --- a/frappe/core/doctype/docfield/docfield.py +++ b/frappe/core/doctype/docfield/docfield.py @@ -87,6 +87,7 @@ class DocField(Document): is_virtual: DF.Check label: DF.Data | None length: DF.Int + link_filters: DF.JSON | None mandatory_depends_on: DF.Code | None max_height: DF.Data | None no_copy: DF.Check diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 9075d20a0f8..d17f110ade1 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -134,6 +134,7 @@ class DocType(Document): is_virtual: DF.Check issingle: DF.Check istable: DF.Check + link_filters: DF.JSON links: DF.Table[DocTypeLink] make_attachments_public: DF.Check max_attachments: DF.Int diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json index 32c4552f19b..b0971a13810 100644 --- a/frappe/custom/doctype/custom_field/custom_field.json +++ b/frappe/custom/doctype/custom_field/custom_field.json @@ -15,6 +15,7 @@ "fieldname", "insert_after", "length", + "link_filters", "column_break_6", "fieldtype", "precision", @@ -446,6 +447,12 @@ "fieldtype": "Check", "label": "Sort Options" }, + { + "fieldname": "link_filters", + "fieldtype": "JSON", + "hidden": 1, + "label": "Link Filters" + }, { "default": "0", "fieldname": "show_dashboard", diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index 4617bdc96a1..4b7c962d2e1 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -94,6 +94,7 @@ class CustomField(Document): is_virtual: DF.Check label: DF.Data | None length: DF.Int + link_filters: DF.JSON | None mandatory_depends_on: DF.Code | None module: DF.Link | None no_copy: DF.Check diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index b3bb3eeb6c5..b025ad33c67 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -12,6 +12,7 @@ "properties", "label", "search_fields", + "link_filters", "column_break_5", "istable", "is_calendar_and_gantt", @@ -382,6 +383,12 @@ "fieldtype": "Tab Break", "label": "Form" }, + { + "fieldname": "link_filters", + "fieldtype": "JSON", + "hidden": 1, + "label": "Link Filters" + }, { "fieldname": "details_tab", "fieldtype": "Tab Break", diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 74461506d80..43b3d10c340 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -55,6 +55,7 @@ class CustomizeForm(Document): is_calendar_and_gantt: DF.Check istable: DF.Check label: DF.Data | None + link_filters: DF.JSON | None links: DF.Table[DocTypeLink] make_attachments_public: DF.Check max_attachments: DF.Int @@ -700,6 +701,17 @@ def is_standard_or_system_generated_field(df): return not df.get("is_custom_field") or df.get("is_system_generated") +@frappe.whitelist() +def get_link_filters_from_doc_without_customisations(doctype, fieldname): + """Get the filters of a link field from a doc without customisations + In backend the customisations are not applied. + Customisations are applied in the client side. + """ + doc = frappe.get_doc("DocType", doctype) + field = list(filter(lambda x: x.fieldname == fieldname, doc.fields)) + return field[0].link_filters + + doctype_properties = { "search_fields": "Data", "title_field": "Data", @@ -780,6 +792,7 @@ def is_standard_or_system_generated_field(df): "hide_days": "Check", "hide_seconds": "Check", "is_virtual": "Check", + "link_filters": "JSON", } doctype_link_properties = { diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.json b/frappe/custom/doctype/customize_form_field/customize_form_field.json index fd559636d3f..0dfd7a6c458 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -24,6 +24,7 @@ "no_copy", "allow_in_quick_entry", "translatable", + "link_filters", "column_break_7", "default", "precision", @@ -472,6 +473,11 @@ "fieldname": "sort_options", "fieldtype": "Check", "label": "Sort Options" + }, + { + "fieldname": "link_filters", + "fieldtype": "JSON", + "label": "Link Filters" } ], "idx": 1, diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.py b/frappe/custom/doctype/customize_form_field/customize_form_field.py index ae8d6ff39df..76ab6535e37 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.py +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.py @@ -86,6 +86,7 @@ class CustomizeFormField(Document): is_virtual: DF.Check label: DF.Data | None length: DF.Int + link_filters: DF.JSON | None mandatory_depends_on: DF.Code | None no_copy: DF.Check non_negative: DF.Check diff --git a/frappe/public/js/form_builder/components/Field.vue b/frappe/public/js/form_builder/components/Field.vue index d853f5533e3..d565aecda71 100644 --- a/frappe/public/js/form_builder/components/Field.vue +++ b/frappe/public/js/form_builder/components/Field.vue @@ -75,6 +75,108 @@ function duplicate_field() { store.form.selected_field = duplicate_field.df; } +function make_dialog(frm) { + frm.dialog = new frappe.ui.Dialog({ + title: __("Set Filters"), + fields: [ + { + fieldtype: "HTML", + fieldname: "filter_area", + }, + ], + primary_action: () => { + let fieldname = props.field.df.fieldname; + let field_option = props.field.df.options; + let filters = frm.filter_group.get_filters().map((filter) => { + // last element is a boolean which hides the filter hence not required to store in meta + filter.pop(); + + // filter_group component requires options and frm.set_query requires fieldname so storing both + filter[0] = field_option; + return filter; + }); + + props.field.df.link_filters = JSON.stringify(filters); + store.form.selected_field = props.field.df; + frm.dialog.hide(); + }, + primary_action_label: __("Apply"), + }); + + if (frm.doctype === "Customize Form") { + let current_doctype = frm.doc.doc_type; + let fieldname = props.field.df.fieldname; + let property = "link_filters"; + let property_setter_id = current_doctype + "-" + fieldname + "-" + property; + + frappe.db.exists("Property Setter", property_setter_id).then((exits) => { + if (exits) { + frm.dialog.set_secondary_action_label(__("Reset To Default")); + frm.dialog.set_secondary_action(() => { + frappe.call({ + method: "frappe.custom.doctype.customize_form.customize_form.get_link_filters_from_doc_without_customisations", + args: { + doctype: current_doctype, + fieldname: fieldname, + }, + callback: function (r) { + if (r.message) { + props.field.df.link_filters = r.message; + + frm.filter_group.clear_filters(); + add_existing_filter(frm, props.field.df); + // hide the secondary action button + frm.dialog.get_secondary_btn().addClass("hidden"); + } + }, + }); + }); + } + }); + } +} + +function make_filter_area(frm, doctype) { + frm.filter_group = new frappe.ui.FilterGroup({ + parent: frm.dialog.get_field("filter_area").$wrapper, + doctype: doctype, + on_change: () => {}, + }); +} + +function add_existing_filter(frm, df) { + if (df.link_filters) { + let filters = JSON.parse(df.link_filters); + if (filters) { + frm.filter_group.add_filters_to_filter_group(filters); + } + } +} + +function edit_filters() { + let field_doctype = props.field.df.options; + const { frm } = store; + + make_dialog(frm); + make_filter_area(frm, field_doctype); + frappe.model.with_doctype(field_doctype, () => { + frm.dialog.show(); + add_existing_filter(frm, props.field.df); + }); +} + +function is_filter_applied() { + if (props.field.df.link_filters) { + try { + if (JSON.parse(props.field.df.link_filters).length > 0) { + return "btn-filter-applied"; + } + } catch (error) { + return ""; + } + } +} + onMounted(() => selected.value && label_input.value.focus_on_label()); @@ -111,22 +213,17 @@ onMounted(() => selected.value && label_input.value.focus_on_label());