diff --git a/.github/helper/db/mariadb.json b/.github/helper/db/mariadb.json index 8bb654da66..e86e701dc3 100644 --- a/.github/helper/db/mariadb.json +++ b/.github/helper/db/mariadb.json @@ -13,5 +13,6 @@ "root_login": "root", "root_password": "travis", "host_name": "http://test_site:8000", + "monitor": 1, "server_script_enabled": true } diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py index eb0b373acd..8156137e3f 100644 --- a/.github/helper/documentation.py +++ b/.github/helper/documentation.py @@ -1,7 +1,7 @@ import sys -import requests from urllib.parse import urlparse +import requests docs_repos = [ "frappe_docs", diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 3c6f8b744c..8422378ef4 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -11,7 +11,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout Actions - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: repository: "frappe/backport" path: ./actions diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml index 7a38648e63..9c2b933763 100644 --- a/.github/workflows/server-mariadb-tests.yml +++ b/.github/workflows/server-mariadb-tests.yml @@ -118,7 +118,8 @@ jobs: env: SITE: test_site CI_BUILD_ID: ${{ github.run_id }} - ORCHESTRATOR_URL: http://test-orchestrator.frappe.io + BUILD_NUMBER: ${{ matrix.container }} + TOTAL_BUILDS: 2 - name: Upload coverage data uses: actions/upload-artifact@v3 diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml index 300f888de6..926a87249f 100644 --- a/.github/workflows/server-postgres-tests.yml +++ b/.github/workflows/server-postgres-tests.yml @@ -121,7 +121,8 @@ jobs: env: SITE: test_site CI_BUILD_ID: ${{ github.run_id }} - ORCHESTRATOR_URL: http://test-orchestrator.frappe.io + BUILD_NUMBER: ${{ matrix.container }} + TOTAL_BUILDS: 2 - name: Upload coverage data uses: actions/upload-artifact@v3 diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 4aa6ed0393..f41171784c 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -37,7 +37,7 @@ jobs: test: runs-on: ubuntu-latest needs: checkrun - if: ${{ needs.checkrun.outputs.build == 'strawberry' }} + if: ${{ needs.checkrun.outputs.build == 'strawberry' && github.repository_owner == 'frappe' }} timeout-minutes: 60 strategy: @@ -120,6 +120,13 @@ jobs: TYPE: ui DB: mariadb + - name: Verify yarn.lock + if: ${{ steps.check-build.outputs.build == 'strawberry' }} + run: | + cd ~/frappe-bench/apps/frappe + yarn install --immutable --immutable-cache --check-cache + git diff --exit-code yarn.lock + - name: Instrument Source Code run: cd ~/frappe-bench/apps/frappe/ && npx nyc instrument -x 'frappe/public/dist/**' -x 'frappe/public/js/lib/**' -x '**/*.bundle.js' --compact=false --in-place frappe @@ -167,7 +174,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Download artifacts uses: actions/download-artifact@v3 diff --git a/codecov.yml b/codecov.yml index 125a7ef014..b16c49c8d6 100644 --- a/codecov.yml +++ b/codecov.yml @@ -22,6 +22,7 @@ coverage: comment: layout: "diff, flags" require_changes: true + show_critical_paths: true flags: server-mariadb: @@ -40,3 +41,24 @@ flags: paths: - "**/*.py" carryforward: true + +profiling: + critical_files_paths: + - /frappe/api.py + - /frappe/app.py + - /frappe/auth.py + - /frappe/boot.py + - /frappe/client.py + - /frappe/handler.py + - /frappe/migrate.py + - /frappe/sessions.py + - /frappe/utils/* + - /frappe/desk/reportview.py + - /frappe/desk/form/* + - /frappe/model/* + - /frappe/core/doctype/doctype/* + - /frappe/core/doctype/data_import/* + - /frappe/core/doctype/user/* + - /frappe/core/doctype/user/* + - /frappe/query_builder/* + - /frappe/database/* diff --git a/cypress/integration/dashboard_links.js b/cypress/integration/dashboard_links.js index 061899ec95..ebcdfa0048 100644 --- a/cypress/integration/dashboard_links.js +++ b/cypress/integration/dashboard_links.js @@ -17,7 +17,7 @@ context("Dashboard links", () => { .window() .its("frappe") .then((frappe) => { - return frappe.xcall("frappe.tests.ui_test_helpers.update_child_table", { + frappe.call("frappe.tests.ui_test_helpers.update_child_table", { name: child_table_doctype_name, }); }); diff --git a/cypress/integration/folder_navigation.js b/cypress/integration/folder_navigation.js index e15a354de0..60fa46bc88 100644 --- a/cypress/integration/folder_navigation.js +++ b/cypress/integration/folder_navigation.js @@ -24,6 +24,7 @@ context("Folder Navigation", () => { it("Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct", () => { //Navigating inside the Attachments folder + cy.wait(500); cy.get('[title="Attachments"] > span').click(); //To check if the URL formed after visiting the attachments folder is correct @@ -36,6 +37,7 @@ context("Folder Navigation", () => { cy.click_modal_primary_button("Create"); //Navigating inside the added folder in the Attachments folder + cy.wait(500); cy.get('[title="Test Folder"] > span').click(); //To check if the URL is correct after visiting the Test Folder @@ -51,7 +53,12 @@ context("Folder Navigation", () => { cy.click_modal_primary_button("Upload"); //To check if the added file is present in the Test Folder - cy.get("span.level-item > span").should("contain", "Test Folder"); + cy.visit("/app/file/view/home/Attachments"); + cy.wait(500); + cy.get("span.level-item > a > span").should("contain", "Test Folder"); + cy.visit("/app/file/view/home/Attachments/Test%20Folder"); + + cy.wait(500); cy.get(".list-row-container").eq(0).should("contain.text", "72402.jpg"); cy.get(".list-row-checkbox").eq(0).click(); diff --git a/cypress/integration/timeline.js b/cypress/integration/timeline.js index 7835819334..c6076088fb 100644 --- a/cypress/integration/timeline.js +++ b/cypress/integration/timeline.js @@ -72,14 +72,14 @@ context("Timeline", () => { cy.click_listview_row_item(0); //To check if the submission of the documemt is visible in the timeline content - cy.get(".timeline-content").should("contain", "Frappe submitted this document"); + cy.get(".timeline-content").should("contain", "You submitted this document"); cy.get('[id="page-Custom Submittable DocType"] .page-actions') .findByRole("button", { name: "Cancel" }) .click(); cy.get_open_dialog().findByRole("button", { name: "Yes" }).click(); //To check if the cancellation of the documemt is visible in the timeline content - cy.get(".timeline-content").should("contain", "Frappe cancelled this document"); + cy.get(".timeline-content").should("contain", "You cancelled this document"); //Deleting the document cy.visit("/app/custom-submittable-doctype"); diff --git a/cypress/integration/view_routing.js b/cypress/integration/view_routing.js new file mode 100644 index 0000000000..1e3b841c79 --- /dev/null +++ b/cypress/integration/view_routing.js @@ -0,0 +1,231 @@ +context("View", () => { + before(() => { + cy.login(); + cy.visit("/app/website"); + }); + + it("Route to ToDo List View", () => { + cy.visit("/app/todo/view/list"); + cy.wait(500); + cy.window() + .its("cur_list") + .then((list) => { + expect(list.view_name).to.equal("List"); + }); + }); + + it("Route to ToDo Report View", () => { + cy.visit("/app/todo/view/report"); + cy.wait(500); + cy.window() + .its("cur_list") + .then((list) => { + expect(list.view_name).to.equal("Report"); + }); + }); + + it("Route to ToDo Dashboard View", () => { + cy.visit("/app/todo/view/dashboard"); + cy.wait(500); + cy.window() + .its("cur_list") + .then((list) => { + expect(list.view_name).to.equal("Dashboard"); + }); + }); + + it("Route to ToDo Gantt View", () => { + cy.visit("/app/todo/view/gantt"); + cy.wait(500); + cy.window() + .its("cur_list") + .then((list) => { + expect(list.view_name).to.equal("Gantt"); + }); + }); + + it("Route to ToDo Kanban View", () => { + cy.call("frappe.tests.ui_test_helpers.create_kanban").then(() => { + cy.visit("/app/note/view/kanban/_Note _Kanban"); + cy.wait(500); + cy.window() + .its("cur_list") + .then((list) => { + expect(list.view_name).to.equal("Kanban"); + }); + }); + }); + + it("Route to ToDo Calendar View", () => { + cy.visit("/app/todo/view/calendar"); + cy.wait(500); + cy.window() + .its("cur_list") + .then((list) => { + expect(list.view_name).to.equal("Calendar"); + }); + }); + + it("Route to Custom Tree View", () => { + cy.call("frappe.tests.ui_test_helpers.setup_tree_doctype").then(() => { + cy.visit("/app/custom-tree/view/tree"); + cy.wait(500); + cy.window() + .its("cur_tree") + .then((list) => { + expect(list.view_name).to.equal("Tree"); + }); + }); + }); + + it("Route to Custom Image View", () => { + cy.call("frappe.tests.ui_test_helpers.setup_image_doctype").then(() => { + cy.visit("app/custom-image/view/image"); + cy.wait(500); + cy.window() + .its("cur_list") + .then((list) => { + expect(list.view_name).to.equal("Image"); + }); + }); + }); + + it("Route to Communication Inbox View", () => { + cy.call("frappe.tests.ui_test_helpers.setup_inbox").then(() => { + cy.visit("app/communication/view/inbox"); + cy.wait(500); + cy.window() + .its("cur_list") + .then((list) => { + expect(list.view_name).to.equal("Inbox"); + }); + }); + }); + + it("Route to File View", () => { + cy.visit("app/file"); + cy.wait(500); + cy.window() + .its("cur_list") + .then((list) => { + expect(list.view_name).to.equal("File"); + expect(list.current_folder).to.equal("Home"); + }); + + cy.visit("app/file/view/home/Attachments"); + cy.wait(500); + cy.window() + .its("cur_list") + .then((list) => { + expect(list.view_name).to.equal("File"); + expect(list.current_folder).to.equal("Home/Attachments"); + }); + }); + + it("Re-route to default view", () => { + cy.call("frappe.tests.ui_test_helpers.setup_default_view", { view: "Report" }).then(() => { + cy.visit("app/event"); + cy.wait(500); + cy.window() + .its("cur_list") + .then((list) => { + expect(list.view_name).to.equal("Report"); + }); + }); + }); + + it("Route to default view from app/{doctype}", () => { + cy.call("frappe.tests.ui_test_helpers.setup_default_view", { view: "Report" }).then(() => { + cy.visit("/app/event"); + cy.wait(500); + cy.window() + .its("cur_list") + .then((list) => { + expect(list.view_name).to.equal("Report"); + }); + }); + }); + + it("Route to default view from app/{doctype}/view", () => { + cy.call("frappe.tests.ui_test_helpers.setup_default_view", { view: "Report" }).then(() => { + cy.visit("/app/event/view"); + cy.wait(500); + cy.window() + .its("cur_list") + .then((list) => { + expect(list.view_name).to.equal("Report"); + }); + }); + }); + + it("Force Route to default view from app/{doctype}", () => { + cy.call("frappe.tests.ui_test_helpers.setup_default_view", { + view: "Report", + force_reroute: true, + }).then(() => { + cy.visit("/app/event"); + cy.wait(500); + cy.window() + .its("cur_list") + .then((list) => { + expect(list.view_name).to.equal("Report"); + }); + }); + }); + + it("Force Route to default view from app/{doctype}/view", () => { + cy.call("frappe.tests.ui_test_helpers.setup_default_view", { + view: "Report", + force_reroute: true, + }).then(() => { + cy.visit("/app/event/view"); + cy.wait(500); + cy.window() + .its("cur_list") + .then((list) => { + expect(list.view_name).to.equal("Report"); + }); + }); + }); + + it("Force Route to default view from app/{doctype}/view", () => { + cy.call("frappe.tests.ui_test_helpers.setup_default_view", { + view: "Report", + force_reroute: true, + }).then(() => { + cy.visit("/app/event/view/list"); + cy.wait(500); + cy.window() + .its("cur_list") + .then((list) => { + expect(list.view_name).to.equal("Report"); + }); + }); + }); + + it("Validate Route History for Default View", () => { + cy.call("frappe.tests.ui_test_helpers.setup_default_view", { view: "Report" }).then(() => { + cy.visit("/app/event"); + cy.visit("/app/event/view/list"); + cy.location("pathname").should("eq", "/app/event/view/list"); + cy.go("back"); + cy.location("pathname").should("eq", "/app/event"); + }); + }); + + it("Route to Form", () => { + cy.call("frappe.tests.ui_test_helpers.create_note").then(() => { + cy.visit("/app/note/Routing Test"); + cy.window() + .its("cur_frm") + .then((frm) => { + expect(frm.doc.title).to.equal("Routing Test"); + }); + }); + }); + + it("Route to Settings Workspace", () => { + cy.visit("/app/settings"); + cy.get(".title-text").should("contain", "Settings"); + }); +}); diff --git a/cypress/integration/workspace_blocks.js b/cypress/integration/workspace_blocks.js index 47c5424bce..1f641de6c3 100644 --- a/cypress/integration/workspace_blocks.js +++ b/cypress/integration/workspace_blocks.js @@ -42,7 +42,7 @@ context("Workspace Blocks", () => { cy.wait("@new_page"); }); - it("Quick List Block", () => { + it.skip("Quick List Block", () => { cy.create_records([ { doctype: "ToDo", @@ -71,7 +71,6 @@ context("Workspace Blocks", () => { url: "api/method/frappe.desk.form.load.getdoctype?**", }).as("get_doctype"); - cy.visit("/app/tools"); cy.get(".codex-editor__redactor .ce-block"); cy.get(".standard-actions .btn-secondary[data-label=Edit]").click(); @@ -79,10 +78,8 @@ context("Workspace Blocks", () => { cy.get(".ce-block").first().click({ force: true }).type("{enter}"); cy.get(".block-list-container .block-list-item").contains("Quick List").click(); - cy.get_open_dialog().find(".modal-header").click(); - + cy.fill_field("label", "ToDo", "Data"); cy.fill_field("document_type", "ToDo", "Link").blur(); - cy.fill_field("label", "ToDo", "Data").blur(); cy.wait("@get_doctype"); cy.get_open_dialog().find(".filter-edit-area").should("contain", "No filters selected"); @@ -122,6 +119,7 @@ context("Workspace Blocks", () => { cy.get_open_dialog() .find(".filter-field .input-with-feedback") + .focus() .type("{selectall}Approved"); cy.get_open_dialog().find(".modal-header").click(); cy.get_open_dialog().find(".btn-primary").click(); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index a51e1daf17..20de7508c0 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -285,7 +285,7 @@ Cypress.Commands.add("get_open_dialog", () => { Cypress.Commands.add("save", () => { cy.intercept("/api/method/frappe.desk.form.save.savedocs").as("save_call"); - cy.get(`button[data-label="Save"]:visible`).click({ scrollBehavior: false, force: true }); + cy.get(`button[data-label="Save"]:visible`).click({ scrollBehavior: "top", force: true }); cy.wait("@save_call"); }); Cypress.Commands.add("hide_dialog", () => { diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js index 69e479a6ff..cfd6b1a1b6 100644 --- a/esbuild/esbuild.js +++ b/esbuild/esbuild.js @@ -3,11 +3,12 @@ const path = require("path"); const fs = require("fs"); const glob = require("fast-glob"); const esbuild = require("esbuild"); -const vue = require("esbuild-vue"); +const vue = require("esbuild-plugin-vue3"); const yargs = require("yargs"); const cliui = require("cliui")(); const chalk = require("chalk"); const html_plugin = require("./frappe-html"); +const vue_style_plugin = require("./frappe-vue-style"); const rtlcss = require("rtlcss"); const postCssPlugin = require("@frappe/esbuild-plugin-postcss2").default; const ignore_assets = require("./ignore-assets"); @@ -218,7 +219,7 @@ function get_files_to_build(files) { } function build_files({ files, outdir }) { - let build_plugins = [html_plugin, build_cleanup_plugin, vue()]; + let build_plugins = [vue(), html_plugin, build_cleanup_plugin, vue_style_plugin]; return esbuild.build(get_build_options(files, outdir, build_plugins)); } @@ -254,6 +255,8 @@ function get_build_options(files, outdir, plugins) { nodePaths: NODE_PATHS, define: { "process.env.NODE_ENV": JSON.stringify(PRODUCTION ? "production" : "development"), + __VUE_OPTIONS_API__: JSON.stringify(true), + __VUE_PROD_DEVTOOLS__: JSON.stringify(false), }, plugins: plugins, watch: get_watch_config(), diff --git a/esbuild/frappe-vue-style.js b/esbuild/frappe-vue-style.js new file mode 100644 index 0000000000..238a6e92e5 --- /dev/null +++ b/esbuild/frappe-vue-style.js @@ -0,0 +1,59 @@ +const fs = require("fs"); +const path = require("path"); +const { sites_path } = require("./utils"); + +module.exports = { + name: "frappe-vue-style", + setup(build) { + build.initialOptions.write = false; + build.onEnd((result) => { + let files = get_files(result.metafile.outputs); + let keys = Object.keys(files); + for (let out of result.outputFiles) { + let asset_path = "/" + path.relative(sites_path, out.path); + let dir = path.dirname(out.path); + if (out.path.endsWith(".js") && keys.includes(asset_path)) { + let name = out.path.split(".bundle.")[0]; + name = path.basename(name); + + let index = result.outputFiles.findIndex((f) => { + return f.path.endsWith(".css") && f.path.includes(`/${name}.bundle.`); + }); + + let css_data = JSON.stringify(result.outputFiles[index].text); + let modified = `frappe.dom.set_style(${css_data});\n${out.text}`; + out.contents = Buffer.from(modified); + + result.outputFiles.splice(index, 1); + if (result.outputFiles[index - 1].path.endsWith(".css.map")) { + result.outputFiles.splice(index - 1, 1); + } + } + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFile(out.path, out.contents, (err) => { + err && console.error(err); + }); + } + }); + }, +}; + +function get_files(files) { + let result = {}; + for (let file in files) { + let info = files[file]; + let asset_path = "/" + path.relative(sites_path, file); + if (info && info.entryPoint && Object.keys(info.inputs).length !== 0) { + for (let input in info.inputs) { + if (input.includes(".vue?type=style")) { + let bundle_css = path.basename(info.entryPoint).replace(".js", ".css"); + result[asset_path] = bundle_css; + break; + } + } + } + } + return result; +} diff --git a/frappe/__init__.py b/frappe/__init__.py index 5ddf1c55a9..f8e4de34d1 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -452,7 +452,7 @@ def msgprint( if as_list and type(msg) in (list, tuple): out.as_list = 1 - if sys.stdin.isatty(): + if sys.stdin and sys.stdin.isatty(): msg = _strip_html_tags(out.message) if flags.print_messages and out.message: diff --git a/frappe/api.py b/frappe/api.py index 1048468077..309adbc564 100644 --- a/frappe/api.py +++ b/frappe/api.py @@ -3,6 +3,7 @@ import base64 import binascii import json +from typing import Literal from urllib.parse import urlencode, urlparse import frappe @@ -49,106 +50,148 @@ def handle(): if len(parts) > 3: name = parts[3] - if call == "method": - frappe.local.form_dict.cmd = doctype - return frappe.handler.handle() + return _RESTAPIHandler(call, doctype, name).get_response() - elif call == "resource": - if "run_method" in frappe.local.form_dict: - method = frappe.local.form_dict.pop("run_method") - doc = frappe.get_doc(doctype, name) - doc.is_whitelisted(method) - if frappe.local.request.method == "GET": - if not doc.has_permission("read"): - frappe.throw(_("Not permitted"), frappe.PermissionError) - frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)}) +class _RESTAPIHandler: + def __init__(self, call: Literal["method", "resource"], doctype: str | None, name: str | None): + self.call = call + self.doctype = doctype + self.name = name - if frappe.local.request.method == "POST": - if not doc.has_permission("write"): - frappe.throw(_("Not permitted"), frappe.PermissionError) + def get_response(self): + """Prepare and get response based on URL and form body. - frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)}) - frappe.db.commit() - - else: - if name: - if frappe.local.request.method == "GET": - doc = frappe.get_doc(doctype, name) - if not doc.has_permission("read"): - raise frappe.PermissionError - frappe.local.response.update({"data": doc}) - - if frappe.local.request.method == "PUT": - data = get_request_form_data() - - doc = frappe.get_doc(doctype, name, for_update=True) - - if "flags" in data: - del data["flags"] - - # Not checking permissions here because it's checked in doc.save - doc.update(data) - - frappe.local.response.update({"data": doc.save().as_dict()}) - - # check for child table doctype - if doc.get("parenttype"): - frappe.get_doc(doc.parenttype, doc.parent).save() - - frappe.db.commit() - - if frappe.local.request.method == "DELETE": - # Not checking permissions here because it's checked in delete_doc - frappe.delete_doc(doctype, name, ignore_missing=False) - frappe.local.response.http_status_code = 202 - frappe.local.response.message = "ok" - frappe.db.commit() - - elif doctype: - if frappe.local.request.method == "GET": - # set fields for frappe.get_list - if frappe.local.form_dict.get("fields"): - frappe.local.form_dict["fields"] = json.loads(frappe.local.form_dict["fields"]) - - # set limit of records for frappe.get_list - frappe.local.form_dict.setdefault( - "limit_page_length", - frappe.local.form_dict.limit or frappe.local.form_dict.limit_page_length or 20, - ) - - # convert strings to native types - only as_dict and debug accept bool - for param in ["as_dict", "debug"]: - param_val = frappe.local.form_dict.get(param) - if param_val is not None: - frappe.local.form_dict[param] = sbool(param_val) - - # evaluate frappe.get_list - data = frappe.call(frappe.client.get_list, doctype, **frappe.local.form_dict) - - # set frappe.get_list result to response - frappe.local.response.update({"data": data}) - - if frappe.local.request.method == "POST": - # fetch data from from dict - data = get_request_form_data() - data.update({"doctype": doctype}) - - # insert document from request data - doc = frappe.get_doc(data).insert() - - # set response data - frappe.local.response.update({"data": doc.as_dict()}) - - # commit for POST requests - frappe.db.commit() - else: + Note: most methods of this class directly operate on the response local. + """ + match self.call: + case "method": + return self.handle_method() + case "resource": + self.handle_resource() + case _: raise frappe.DoesNotExistError - else: - raise frappe.DoesNotExistError + return build_response("json") - return build_response("json") + def handle_method(self): + frappe.local.form_dict.cmd = self.doctype + return frappe.handler.handle() + + def handle_resource(self): + if self.doctype and self.name: + self.handle_document_resource() + elif self.doctype: + self.handle_doctype_resource() + else: + raise frappe.DoesNotExistError + + def handle_document_resource(self): + if "run_method" in frappe.local.form_dict: + self.execute_doc_method() + return + + match frappe.local.request.method: + case "GET": + self.get_doc() + case "PUT": + self.update_doc() + case "DELETE": + self.delete_doc() + case _: + raise frappe.DoesNotExistError + + def handle_doctype_resource(self): + match frappe.local.request.method: + case "GET": + self.get_doc_list() + case "POST": + self.create_doc() + case _: + raise frappe.DoesNotExistError + + def execute_doc_method(self): + method = frappe.local.form_dict.pop("run_method") + doc = frappe.get_doc(self.doctype, self.name) + doc.is_whitelisted(method) + + if frappe.local.request.method == "GET": + if not doc.has_permission("read"): + frappe.throw(_("Not permitted"), frappe.PermissionError) + frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)}) + + elif frappe.local.request.method == "POST": + if not doc.has_permission("write"): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)}) + frappe.db.commit() + + def get_doc(self): + doc = frappe.get_doc(self.doctype, self.name) + if not doc.has_permission("read"): + raise frappe.PermissionError + frappe.local.response.update({"data": doc}) + + def update_doc(self): + data = get_request_form_data() + + doc = frappe.get_doc(self.doctype, self.name, for_update=True) + + if "flags" in data: + del data["flags"] + + # Not checking permissions here because it's checked in doc.save + doc.update(data) + + frappe.local.response.update({"data": doc.save().as_dict()}) + + # check for child table doctype + if doc.get("parenttype"): + frappe.get_doc(doc.parenttype, doc.parent).save() + frappe.db.commit() + + def delete_doc(self): + # Not checking permissions here because it's checked in delete_doc + frappe.delete_doc(self.doctype, self.name, ignore_missing=False) + frappe.local.response.http_status_code = 202 + frappe.local.response.message = "ok" + frappe.db.commit() + + def get_doc_list(self): + if frappe.local.form_dict.get("fields"): + frappe.local.form_dict["fields"] = json.loads(frappe.local.form_dict["fields"]) + + # set limit of records for frappe.get_list + frappe.local.form_dict.setdefault( + "limit_page_length", + frappe.local.form_dict.limit or frappe.local.form_dict.limit_page_length or 20, + ) + + # convert strings to native types - only as_dict and debug accept bool + for param in ["as_dict", "debug"]: + param_val = frappe.local.form_dict.get(param) + if param_val is not None: + frappe.local.form_dict[param] = sbool(param_val) + + # evaluate frappe.get_list + data = frappe.call(frappe.client.get_list, self.doctype, **frappe.local.form_dict) + + # set frappe.get_list result to response + frappe.local.response.update({"data": data}) + + def create_doc(self): + data = get_request_form_data() + data.update({"doctype": self.doctype}) + + # insert document from request data + doc = frappe.get_doc(data).insert() + + # set response data + frappe.local.response.update({"data": doc.as_dict()}) + + # commit for POST requests + frappe.db.commit() def get_request_form_data(): diff --git a/frappe/boot.py b/frappe/boot.py index 0fe5f93c3e..31e101aedc 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -101,6 +101,7 @@ def get_bootinfo(): bootinfo.app_logo_url = get_app_logo() bootinfo.link_title_doctypes = get_link_title_doctypes() bootinfo.translated_doctypes = get_translated_doctypes() + bootinfo.subscription_expiry = add_subscription_expiry() return bootinfo @@ -428,3 +429,10 @@ def load_currency_docs(bootinfo): ) bootinfo.docs += currency_docs + + +def add_subscription_expiry(): + try: + return frappe.conf.subscription["expiry"] + except Exception: + return "" diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py index aae7b804d0..d4ce92f384 100644 --- a/frappe/cache_manager.py +++ b/frappe/cache_manager.py @@ -129,12 +129,18 @@ def clear_doctype_cache(doctype=None): clear_single(doctype) # clear all parent doctypes - for dt in frappe.get_all( "DocField", "parent", dict(fieldtype=["in", frappe.model.table_fields], options=doctype) ): clear_single(dt.parent) + # clear all parent doctypes + if not frappe.flags.in_install: + for dt in frappe.get_all( + "Custom Field", "dt", dict(fieldtype=["in", frappe.model.table_fields], options=doctype) + ): + clear_single(dt.dt) + # clear all notifications delete_notification_count_for(doctype) diff --git a/frappe/client.py b/frappe/client.py index 3c0ce8ea8a..bec3aefb7b 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -270,7 +270,7 @@ def delete(doctype, name): :param doctype: DocType of the document to be deleted :param name: name of the document to be deleted""" - frappe.delete_doc(doctype, name, ignore_missing=False) + delete_doc(doctype, name) @frappe.whitelist(methods=["POST", "PUT"]) @@ -462,3 +462,24 @@ def insert_doc(doc) -> "Document": return parent return frappe.get_doc(doc).insert() + + +def delete_doc(doctype, name): + """Deletes document + if doctype is a child table, then deletes the child record using the parent doc + so that the parent doc's `on_update` is called + """ + + if frappe.is_table(doctype): + values = frappe.db.get_value(doctype, name, ["parenttype", "parent", "parentfield"]) + if not values: + raise frappe.DoesNotExistError + parenttype, parent, parentfield = values + parent = frappe.get_doc(parenttype, parent) + for row in parent.get(parentfield): + if row.name == name: + parent.remove(row) + parent.save() + break + else: + frappe.delete_doc(doctype, name, ignore_missing=False) diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 07061444b0..e555f63f41 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -819,9 +819,16 @@ def run_tests( @click.option("--total-builds", help="Total number of builds", default=1) @click.option("--with-coverage", is_flag=True, help="Build coverage file") @click.option("--use-orchestrator", is_flag=True, help="Use orchestrator to run parallel tests") +@click.option("--dry-run", is_flag=True, default=False, help="Dont actually run tests") @pass_context def run_parallel_tests( - context, app, build_number, total_builds, with_coverage=False, use_orchestrator=False + context, + app, + build_number, + total_builds, + with_coverage=False, + use_orchestrator=False, + dry_run=False, ): from traceback_with_variables import activate_by_import @@ -834,7 +841,13 @@ def run_parallel_tests( else: from frappe.parallel_test_runner import ParallelTestRunner - ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds) + ParallelTestRunner( + app, + site=site, + build_number=build_number, + total_builds=total_builds, + dry_run=dry_run, + ) @click.command( diff --git a/frappe/config/__init__.py b/frappe/config/__init__.py index 947f61e9e0..02626aedf5 100644 --- a/frappe/config/__init__.py +++ b/frappe/config/__init__.py @@ -1,11 +1,5 @@ import frappe from frappe import _ -from frappe.desk.moduleview import ( - config_exists, - get_data, - get_module_link_items_from_list, - get_onboard_items, -) def get_modules_from_all_apps_for_user(user: str = None) -> list[dict]: @@ -25,9 +19,6 @@ def get_modules_from_all_apps_for_user(user: str = None) -> list[dict]: if module_name in empty_tables_by_module: module["onboard_present"] = 1 - # Set defaults links - module["links"] = get_onboard_items(module["app"], frappe.scrub(module_name))[:5] - return allowed_modules_list diff --git a/frappe/contacts/doctype/address/address.py b/frappe/contacts/doctype/address/address.py index 42dbdd6177..5fe22eb7f2 100644 --- a/frappe/contacts/doctype/address/address.py +++ b/frappe/contacts/doctype/address/address.py @@ -228,11 +228,12 @@ def get_company_address(company): def address_query(doctype, txt, searchfield, start, page_len, filters): from frappe.desk.reportview import get_match_cond + doctype = "Address" link_doctype = filters.pop("link_doctype") link_name = filters.pop("link_name") condition = "" - meta = frappe.get_meta("Address") + meta = frappe.get_meta(doctype) for fieldname, value in filters.items(): if meta.get_field(fieldname) or fieldname in frappe.db.DEFAULT_COLUMNS: condition += f" and {fieldname}={frappe.db.escape(value)}" diff --git a/frappe/contacts/doctype/contact/contact.py b/frappe/contacts/doctype/contact/contact.py index a17f46216b..1ed4307d8d 100644 --- a/frappe/contacts/doctype/contact/contact.py +++ b/frappe/contacts/doctype/contact/contact.py @@ -210,8 +210,9 @@ def update_contact(doc, method): def contact_query(doctype, txt, searchfield, start, page_len, filters): from frappe.desk.reportview import get_match_cond + doctype = "Contact" if ( - not frappe.get_meta("Contact").get_field(searchfield) + not frappe.get_meta(doctype).get_field(searchfield) and searchfield not in frappe.db.DEFAULT_COLUMNS ): return [] diff --git a/frappe/core/doctype/communication/communication.json b/frappe/core/doctype/communication/communication.json index f9d15af483..293a6b2c87 100644 --- a/frappe/core/doctype/communication/communication.json +++ b/frappe/core/doctype/communication/communication.json @@ -2,6 +2,7 @@ "actions": [], "allow_import": 1, "creation": "2013-01-29 10:47:14", + "default_view": "Inbox", "description": "Keeps track of all communications", "doctype": "DocType", "document_type": "Setup", @@ -198,7 +199,6 @@ "label": "More Information" }, { - "bold": 0, "default": "Now", "fieldname": "communication_date", "fieldtype": "Datetime", @@ -395,7 +395,7 @@ "icon": "fa fa-comment", "idx": 1, "links": [], - "modified": "2022-03-30 11:24:25.728637", + "modified": "2022-05-09 00:13:45.310564", "modified_by": "Administrator", "module": "Core", "name": "Communication", @@ -454,8 +454,9 @@ "sender_field": "sender", "sort_field": "modified", "sort_order": "DESC", + "states": [], "subject_field": "subject", "title_field": "subject", "track_changes": 1, "track_seen": 1 -} +} \ No newline at end of file diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index c049ccff45..bd85023025 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -19,6 +19,7 @@ from frappe.core.doctype.communication.mixins import CommunicationEmailMixin from frappe.core.utils import get_parent_doc from frappe.model.document import Document from frappe.utils import ( + cstr, parse_addr, split_emails, strip_html, @@ -175,7 +176,7 @@ class Communication(Document, CommunicationEmailMixin): if html_signature: _signature = html_signature.renderContents() - if (_signature or signature) not in self.content: + if (cstr(_signature) or signature) not in self.content: self.content = f'{self.content}


{signature}' def before_save(self): diff --git a/frappe/core/doctype/communication/mixins.py b/frappe/core/doctype/communication/mixins.py index bfadaf4f6c..85de33841f 100644 --- a/frappe/core/doctype/communication/mixins.py +++ b/frappe/core/doctype/communication/mixins.py @@ -165,7 +165,8 @@ class CommunicationEmailMixin: ) if self.sent_or_received == "Sent" and self._outgoing_email_account: - self.db_set("email_account", self._outgoing_email_account.name) + if frappe.db.exists("Email Account", self._outgoing_email_account.name): + self.db_set("email_account", self._outgoing_email_account.name) return self._outgoing_email_account diff --git a/frappe/core/doctype/data_export/data_export.js b/frappe/core/doctype/data_export/data_export.js index 8d65a209b5..54677b98a6 100644 --- a/frappe/core/doctype/data_export/data_export.js +++ b/frappe/core/doctype/data_export/data_export.js @@ -27,6 +27,9 @@ frappe.ui.form.on("Data Export", { reset_filter_and_field(frm); } }, + export_without_main_header: (frm) => { + frm.refresh(); + }, }); const can_export = (frm) => { @@ -58,8 +61,9 @@ const export_data = (frm) => { select_columns: JSON.stringify(columns), filters: frm.filter_list.get_filters().map((filter) => filter.slice(1, 4)), file_type: frm.doc.file_type, - template: true, + template: !frm.doc.export_without_main_header, with_data: 1, + export_without_column_meta: frm.doc.export_without_main_header ? true : false, }; }; diff --git a/frappe/core/doctype/data_export/data_export.json b/frappe/core/doctype/data_export/data_export.json index 01a680503d..f63d939499 100644 --- a/frappe/core/doctype/data_export/data_export.json +++ b/frappe/core/doctype/data_export/data_export.json @@ -6,6 +6,7 @@ "engine": "InnoDB", "field_order": [ "reference_doctype", + "export_without_main_header", "column_break_2", "file_type", "section_break", @@ -47,12 +48,19 @@ "fieldname": "fields_multicheck", "fieldtype": "HTML", "label": "Fields Multicheck" + }, + { + "default": "0", + "description": "Export the data without any header notes and column descriptions", + "fieldname": "export_without_main_header", + "fieldtype": "Check", + "label": "Export without main header" } ], "hide_toolbar": 1, "issingle": 1, "links": [], - "modified": "2022-08-03 12:20:53.658574", + "modified": "2022-09-28 03:51:02.404681", "modified_by": "Administrator", "module": "Core", "name": "Data Export", diff --git a/frappe/core/doctype/data_export/exporter.py b/frappe/core/doctype/data_export/exporter.py index b7f69ab43d..e3bf669630 100644 --- a/frappe/core/doctype/data_export/exporter.py +++ b/frappe/core/doctype/data_export/exporter.py @@ -37,6 +37,7 @@ def export_data( file_type="CSV", template=False, filters=None, + export_without_column_meta=False, ): _doctype = doctype if isinstance(_doctype, list): @@ -48,6 +49,15 @@ def export_data( filters=filters, method=parent_doctype, ) + + template_bool = template + if isinstance(template, str): + template_bool = template.lower() == "true" + + export_without_column_meta_bool = export_without_column_meta + if isinstance(export_without_column_meta, str): + export_without_column_meta_bool = export_without_column_meta.lower() == "true" + exporter = DataExporter( doctype=doctype, parent_doctype=parent_doctype, @@ -55,8 +65,9 @@ def export_data( with_data=with_data, select_columns=select_columns, file_type=file_type, - template=template, + template=template_bool, filters=filters, + export_without_column_meta=export_without_column_meta_bool, ) exporter.build_response() @@ -72,6 +83,7 @@ class DataExporter: file_type="CSV", template=False, filters=None, + export_without_column_meta=False, ): self.doctype = doctype self.parent_doctype = parent_doctype @@ -81,6 +93,7 @@ class DataExporter: self.file_type = file_type self.template = template self.filters = filters + self.export_without_column_meta = export_without_column_meta self.data_keys = get_data_keys() self.prepare_args() @@ -117,7 +130,10 @@ class DataExporter: if self.template: self.add_main_header() - self.writer.writerow([""]) + # No need of empty row at the start + if not self.export_without_column_meta: + self.writer.writerow([""]) + self.tablerow = [self.data_keys.doctype] self.labelrow = [_("Column Labels:")] self.fieldrow = [self.data_keys.columns] @@ -310,12 +326,18 @@ class DataExporter: return "" def add_field_headings(self): - self.writer.writerow(self.tablerow) + if not self.export_without_column_meta: + self.writer.writerow(self.tablerow) + + # Just include Labels in the first row self.writer.writerow(self.labelrow) - self.writer.writerow(self.fieldrow) - self.writer.writerow(self.mandatoryrow) - self.writer.writerow(self.typerow) - self.writer.writerow(self.inforow) + + if not self.export_without_column_meta: + self.writer.writerow(self.fieldrow) + self.writer.writerow(self.mandatoryrow) + self.writer.writerow(self.typerow) + self.writer.writerow(self.inforow) + if self.template: self.writer.writerow([self.data_keys.data_separator]) diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index 7ff25118b1..ea90b24a6f 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -449,7 +449,7 @@ class ImportFile: continue if not header: - header = Header(i, row, self.doctype, self.raw_data, self.column_to_field_map) + header = Header(i, row, self.doctype, self.raw_data[1:], self.column_to_field_map) else: row_obj = Row(i, row, self.doctype, header, self.import_type) data.append(row_obj) @@ -572,12 +572,15 @@ class ImportFile: ###### - def read_file(self, file_path): + def read_file(self, file_path: str): extn = os.path.splitext(file_path)[1][1:] file_content = None - with open(file_path, mode="rb") as f: - file_content = f.read() + + file_name = frappe.db.get_value("File", {"file_url": file_path}) + if file_name: + file = frappe.get_doc("File", file_name) + file_content = file.get_content() return file_content, extn @@ -981,9 +984,12 @@ class Column: if self.skip_import: return + if not any(self.column_values): + return + if self.df.fieldtype == "Link": # find all values that dont exist - values = list({cstr(v) for v in self.column_values[1:] if v}) + values = list({cstr(v) for v in self.column_values if v}) exists = [ cstr(d.name) for d in frappe.get_all(self.df.options, filters={"name": ("in", values)}) ] @@ -1022,7 +1028,7 @@ class Column: elif self.df.fieldtype == "Select": options = get_select_options(self.df) if options: - values = {cstr(v) for v in self.column_values[1:] if v} + values = {cstr(v) for v in self.column_values if v} invalid = values - set(options) if invalid: valid_values = ", ".join(frappe.bold(o) for o in options) diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js index e91a05e17d..24c367b115 100644 --- a/frappe/core/doctype/doctype/doctype.js +++ b/frappe/core/doctype/doctype/doctype.js @@ -55,6 +55,7 @@ frappe.ui.form.on("DocType", { if (frm.is_new()) { frm.events.set_default_permission(frm); + frm.set_value("default_view", "List"); } else { frm.toggle_enable("engine", 0); } @@ -66,12 +67,14 @@ frappe.ui.form.on("DocType", { frm.cscript.autoname(frm); frm.cscript.set_naming_rule_description(frm); + frm.trigger("setup_default_views"); }, istable: (frm) => { if (frm.doc.istable && frm.is_new()) { frm.set_value("autoname", "autoincrement"); frm.set_value("allow_rename", 0); + frm.set_value("default_view", null); } else if (!frm.doc.istable && !frm.is_new()) { frm.events.set_default_permission(frm); } @@ -82,6 +85,18 @@ frappe.ui.form.on("DocType", { frm.add_child("permissions", { role: "System Manager" }); } }, + + is_tree: (frm) => { + frm.trigger("setup_default_views"); + }, + + is_calendar_and_gantt: (frm) => { + frm.trigger("setup_default_views"); + }, + + setup_default_views: (frm) => { + frappe.model.set_default_views_for_doctype(frm.doc.name, frm); + }, }); frappe.ui.form.on("DocField", { @@ -171,6 +186,10 @@ frappe.ui.form.on("DocField", { fieldtype: function (frm) { frm.trigger("max_attachments"); }, + + fields_add: (frm) => { + frm.trigger("setup_default_views"); + }, }); extend_cscript(cur_frm.cscript, new frappe.model.DocTypeController({ frm: cur_frm })); diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index bfa91cea75..6258241f5d 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -14,6 +14,7 @@ "istable", "issingle", "is_tree", + "is_calendar_and_gantt", "editable_grid", "quick_entry", "cb01", @@ -53,6 +54,8 @@ "default_print_format", "sort_field", "sort_order", + "default_view", + "force_re_route_to_default_view", "column_break_29", "document_type", "icon", @@ -320,7 +323,8 @@ "depends_on": "eval:!doc.istable", "fieldname": "title_field", "fieldtype": "Data", - "label": "Title Field" + "label": "Title Field", + "mandatory_depends_on": "eval:doc.show_title_field_in_link" }, { "depends_on": "eval:!doc.istable", @@ -605,6 +609,24 @@ "fieldname": "make_attachments_public", "fieldtype": "Check", "label": "Make Attachments Public by Default" + }, + { + "fieldname": "default_view", + "fieldtype": "Select", + "label": "Default View" + }, + { + "default": "0", + "fieldname": "force_re_route_to_default_view", + "fieldtype": "Check", + "label": "Force Re-route to Default View" + }, + { + "default": "0", + "description": "Enables Calendar and Gantt views.", + "fieldname": "is_calendar_and_gantt", + "fieldtype": "Check", + "label": "Is Calendar and Gantt" } ], "icon": "fa fa-bolt", @@ -687,7 +709,7 @@ "link_fieldname": "reference_doctype" } ], - "modified": "2022-08-24 06:42:27.779699", + "modified": "2022-10-12 14:13:27.315351", "modified_by": "Administrator", "module": "Core", "name": "DocType", diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 44f2877e2b..3722e5d1fa 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -670,6 +670,18 @@ class TestDocType(FrappeTestCase): self.assertEqual(test_json.test_json_field["hello"], "world") + @patch.dict(frappe.conf, {"developer_mode": 1}) + def test_custom_field_deletion(self): + """Custom child tables whose doctype doesn't exist should be auto deleted.""" + doctype = new_doctype(custom=0).insert().name + child = new_doctype(custom=0, istable=1).insert().name + + field = "abc" + create_custom_fields({doctype: [{"fieldname": field, "fieldtype": "Table", "options": child}]}) + + frappe.delete_doc("DocType", child) + self.assertFalse(frappe.get_meta(doctype).get_field(field)) + @patch.dict(frappe.conf, {"developer_mode": 1}) def test_delete_doctype_with_customization(self): from frappe.custom.doctype.property_setter.property_setter import make_property_setter diff --git a/frappe/core/doctype/file/file.json b/frappe/core/doctype/file/file.json index cb727e48f0..d6c4a99bc3 100644 --- a/frappe/core/doctype/file/file.json +++ b/frappe/core/doctype/file/file.json @@ -2,6 +2,7 @@ "actions": [], "allow_import": 1, "creation": "2012-12-12 11:19:22", + "default_view": "File", "doctype": "DocType", "engine": "InnoDB", "field_order": [ @@ -169,10 +170,11 @@ "read_only": 1 } ], + "force_re_route_to_default_view": 1, "icon": "fa fa-file", "idx": 1, "links": [], - "modified": "2022-09-13 15:50:15.508250", + "modified": "2022-09-13 15:50:15.508251", "modified_by": "Administrator", "module": "Core", "name": "File", diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index e1faf331d6..1518c72f95 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -422,7 +422,6 @@ class File(Document): return os.path.exists(self.get_full_path()) def get_content(self) -> bytes: - """Returns [`file_name`, `content`] for given file name `fname`""" if self.is_folder: frappe.throw(_("Cannot get file contents of a Folder")) diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py index 0ff4ce3070..0b8e25229d 100644 --- a/frappe/core/doctype/prepared_report/prepared_report.py +++ b/frappe/core/doctype/prepared_report/prepared_report.py @@ -8,6 +8,7 @@ import frappe from frappe.desk.form.load import get_attachments from frappe.desk.query_report import generate_report_result from frappe.model.document import Document +from frappe.monitor import add_data_to_monitor from frappe.utils import gzip_compress, gzip_decompress from frappe.utils.background_jobs import enqueue @@ -25,6 +26,8 @@ def run_background(prepared_report): instance = frappe.get_doc("Prepared Report", prepared_report) report = frappe.get_doc("Report", instance.ref_report_doctype) + add_data_to_monitor(report=instance.ref_report_doctype) + try: report.custom_columns = [] diff --git a/frappe/core/doctype/report/report.js b/frappe/core/doctype/report/report.js index c912e217a6..9850dbf98f 100644 --- a/frappe/core/doctype/report/report.js +++ b/frappe/core/doctype/report/report.js @@ -8,26 +8,28 @@ frappe.ui.form.on("Report", { } let doc = frm.doc; - frm.add_custom_button( - __("Show Report"), - function () { - switch (doc.report_type) { - case "Report Builder": - frappe.set_route("List", doc.ref_doctype, "Report", doc.name); - break; - case "Query Report": - frappe.set_route("query-report", doc.name); - break; - case "Script Report": - frappe.set_route("query-report", doc.name); - break; - case "Custom Report": - frappe.set_route("query-report", doc.name); - break; - } - }, - "fa fa-table" - ); + if (!doc.__islocal) { + frm.add_custom_button( + __("Show Report"), + function () { + switch (doc.report_type) { + case "Report Builder": + frappe.set_route("List", doc.ref_doctype, "Report", doc.name); + break; + case "Query Report": + frappe.set_route("query-report", doc.name); + break; + case "Script Report": + frappe.set_route("query-report", doc.name); + break; + case "Custom Report": + frappe.set_route("query-report", doc.name); + break; + } + }, + "fa fa-table" + ); + } if (doc.is_standard === "Yes" && frm.perm[0].write) { frm.add_custom_button( diff --git a/frappe/core/doctype/rq_worker/rq_worker.py b/frappe/core/doctype/rq_worker/rq_worker.py index bc1326f522..b2d1f1209d 100644 --- a/frappe/core/doctype/rq_worker/rq_worker.py +++ b/frappe/core/doctype/rq_worker/rq_worker.py @@ -23,8 +23,10 @@ class RQWorker(Document): start = cint(args.get("start")) or 0 page_length = cint(args.get("page_length")) or 20 - workers = get_workers()[start : start + page_length] - return [serialize_worker(worker) for worker in workers] + workers = get_workers() + + valid_workers = [w for w in workers if w.pid][start : start + page_length] + return [serialize_worker(worker) for worker in valid_workers] @staticmethod def get_count(args) -> int: diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 50f8697296..092f7fa45d 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -237,7 +237,7 @@ class User(Document): ) def share_with_self(self): - frappe.share.add( + frappe.share.add_docshare( self.doctype, self.name, self.name, write=1, share=1, flags={"ignore_share_permission": True} ) @@ -901,6 +901,7 @@ def reset_password(user): def user_query(doctype, txt, searchfield, start, page_len, filters): from frappe.desk.reportview import get_filters_cond, get_match_cond + doctype = "User" conditions = [] user_type_condition = "and user_type != 'Website User'" diff --git a/frappe/core/page/recorder/recorder.js b/frappe/core/page/recorder/recorder.js index 83b8d1a636..1f004915fe 100644 --- a/frappe/core/page/recorder/recorder.js +++ b/frappe/core/page/recorder/recorder.js @@ -22,7 +22,7 @@ class Recorder { } show() { - if (!this.view || this.view.$route.name == "recorder-detail") return; - this.view.$router.replace({ name: "recorder-detail" }); + if (!this.route || this.route.name == "RecorderDetail") return; + this.router?.replace({ name: "RecorderDetail" }); } } diff --git a/frappe/translations/en_gb.csv b/frappe/core/report/database_storage_usage_by_tables/__init__.py similarity index 100% rename from frappe/translations/en_gb.csv rename to frappe/core/report/database_storage_usage_by_tables/__init__.py 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 new file mode 100644 index 0000000000..b2cf268b36 --- /dev/null +++ b/frappe/core/report/database_storage_usage_by_tables/database_storage_usage_by_tables.js @@ -0,0 +1,7 @@ +// Copyright (c) 2022, Frappe Technologies and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Database Storage Usage By Tables"] = { + filters: [], +}; diff --git a/frappe/core/report/database_storage_usage_by_tables/database_storage_usage_by_tables.json b/frappe/core/report/database_storage_usage_by_tables/database_storage_usage_by_tables.json new file mode 100644 index 0000000000..20deb78ad6 --- /dev/null +++ b/frappe/core/report/database_storage_usage_by_tables/database_storage_usage_by_tables.json @@ -0,0 +1,28 @@ +{ + "add_total_row": 1, + "columns": [], + "creation": "2022-10-19 02:25:24.326791", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letter_head": "abc", + "modified": "2022-10-19 02:59:00.365307", + "modified_by": "Administrator", + "module": "Core", + "name": "Database Storage Usage By Tables", + "owner": "Administrator", + "prepared_report": 0, + "query": "", + "ref_doctype": "Error Log", + "report_name": "Database Storage Usage By Tables", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + } + ] +} \ No newline at end of file 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 new file mode 100644 index 0000000000..c88262552e --- /dev/null +++ b/frappe/core/report/database_storage_usage_by_tables/database_storage_usage_by_tables.py @@ -0,0 +1,40 @@ +# Copyright (c) 2022, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe + +COLUMNS = [ + {"label": "Table", "fieldname": "table", "fieldtype": "Data", "width": 200}, + {"label": "Size (MB)", "fieldname": "size", "fieldtype": "Float"}, + {"label": "Data (MB)", "fieldname": "data_size", "fieldtype": "Float"}, + {"label": "Index (MB)", "fieldname": "index_size", "fieldtype": "Float"}, +] + + +def execute(filters=None): + frappe.only_for("System Manager") + + data = frappe.db.multisql( + { + "mariadb": """ + SELECT table_name AS `table`, + round(((data_length + index_length) / 1024 / 1024), 2) `size`, + round((data_length / 1024 / 1024), 2) as data_size, + round((index_length / 1024 / 1024), 2) as index_size + FROM information_schema.TABLES + ORDER BY (data_length + index_length) DESC; + """, + "postgres": """ + SELECT + table_name as "table", + round(pg_total_relation_size(quote_ident(table_name)) / 1024 / 1024, 2) as "size", + round(pg_relation_size(quote_ident(table_name)) / 1024 / 1024, 2) as "data_size", + round(pg_indexes_size(quote_ident(table_name)) / 1024 / 1024, 2) as "index_size" + FROM information_schema.tables + WHERE table_schema = 'public' + ORDER BY 2 DESC; + """, + }, + as_dict=1, + ) + return COLUMNS, data diff --git a/frappe/core/report/database_storage_usage_by_tables/test_database_storage_usage_by_tables.py b/frappe/core/report/database_storage_usage_by_tables/test_database_storage_usage_by_tables.py new file mode 100644 index 0000000000..e82cbe9caf --- /dev/null +++ b/frappe/core/report/database_storage_usage_by_tables/test_database_storage_usage_by_tables.py @@ -0,0 +1,15 @@ +# Copyright (c) 2022, Frappe Technologies and contributors +# For license information, please see license.txt + + +from frappe.core.report.database_storage_usage_by_tables.database_storage_usage_by_tables import ( + execute, +) +from frappe.tests.utils import FrappeTestCase + + +class TestDBUsageReport(FrappeTestCase): + def test_basic_query(self): + _, data = execute() + tables = [d.table for d in data] + self.assertFalse({"tabUser", "tabDocField"}.difference(tables)) diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index f50ceb1992..7b55b4bc6b 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -102,6 +102,20 @@ class CustomField(Document): # delete property setter entries frappe.db.delete("Property Setter", {"doc_type": self.dt, "field_name": self.fieldname}) + + # update doctype layouts + doctype_layouts = frappe.get_all( + "DocType Layout", filters={"document_type": self.dt}, pluck="name" + ) + + for layout in doctype_layouts: + layout_doc = frappe.get_doc("DocType Layout", layout) + for field in layout_doc.fields: + if field.fieldname == self.fieldname: + layout_doc.remove(field) + layout_doc.save() + break + frappe.clear_cache(doctype=self.dt) def validate_insert_after(self, meta): diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js index a35db2ca18..759a9e1b3a 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -72,6 +72,7 @@ frappe.ui.form.on("Customize Form", { } else { frm.refresh(); frm.trigger("setup_sortable"); + frm.trigger("setup_default_views"); } } localStorage["customize_doctype"] = frm.doc.doc_type; @@ -82,8 +83,12 @@ frappe.ui.form.on("Customize Form", { } }, + is_calendar_and_gantt: function (frm) { + frm.trigger("setup_default_views"); + }, + setup_sortable: function (frm) { - frm.doc.fields.forEach(function (f, i) { + frm.doc.fields.forEach(function (f) { if (!f.is_custom_field) { f._sortable = false; } @@ -178,6 +183,7 @@ frappe.ui.form.on("Customize Form", { fieldname: "module", options: "Module Def", label: __("Module to Export"), + reqd: 1, }, { fieldtype: "Check", @@ -221,6 +227,10 @@ frappe.ui.form.on("Customize Form", { frm.set_df_property("sort_field", "options", fields); } }, + + setup_default_views(frm) { + frappe.model.set_default_views_for_doctype(frm.doc.doc_type, frm); + }, }); // can't delete standard fields @@ -236,6 +246,7 @@ frappe.ui.form.on("Customize Form Field", { var f = frappe.model.get_doc(cdt, cdn); f.is_system_generated = false; f.is_custom_field = true; + frm.trigger("setup_default_views"); }, }); diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index 05989eaa00..4840184966 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -13,6 +13,7 @@ "search_fields", "column_break_5", "istable", + "is_calendar_and_gantt", "editable_grid", "quick_entry", "track_changes", @@ -35,6 +36,8 @@ "show_title_field_in_link", "translated_doctype", "default_print_format", + "default_view", + "force_re_route_to_default_view", "column_break_29", "show_preview_popup", "email_settings_section", @@ -337,6 +340,25 @@ "fieldname": "make_attachments_public", "fieldtype": "Check", "label": "Make Attachments Public by Default" + }, + { + "fieldname": "default_view", + "fieldtype": "Select", + "label": "Default View" + }, + { + "default": "0", + "depends_on": "default_view", + "fieldname": "force_re_route_to_default_view", + "fieldtype": "Check", + "label": "Force Re-route to Default View" + }, + { + "default": "0", + "description": "Enables Calendar and Gantt views.", + "fieldname": "is_calendar_and_gantt", + "fieldtype": "Check", + "label": "Is Calendar and Gantt" } ], "hide_toolbar": 1, @@ -345,7 +367,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2022-08-24 06:57:47.966331", + "modified": "2022-08-30 11:45:16.772277", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form", diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index cacd38397a..be27ebbc0b 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -586,6 +586,10 @@ doctype_properties = { "naming_rule": "Data", "autoname": "Data", "show_title_field_in_link": "Check", + "translate_link_fields": "Check", + "is_calendar_and_gantt": "Check", + "default_view": "Select", + "force_re_route_to_default_view": "Check", "translated_doctype": "Check", } diff --git a/frappe/custom/doctype/customize_form/test_customize_form.py b/frappe/custom/doctype/customize_form/test_customize_form.py index 6c4564c958..661652c74c 100644 --- a/frappe/custom/doctype/customize_form/test_customize_form.py +++ b/frappe/custom/doctype/customize_form/test_customize_form.py @@ -54,7 +54,7 @@ class TestCustomizeForm(FrappeTestCase): d = self.get_customize_form("Event") self.assertEqual(d.doc_type, "Event") - self.assertEqual(len(d.get("fields")), 36) + self.assertEqual(len(d.get("fields")), 38) d = self.get_customize_form("Event") self.assertEqual(d.doc_type, "Event") diff --git a/frappe/custom/doctype/doctype_layout/doctype_layout.js b/frappe/custom/doctype/doctype_layout/doctype_layout.js index f91f04f762..b212b79a5b 100644 --- a/frappe/custom/doctype/doctype_layout/doctype_layout.js +++ b/frappe/custom/doctype/doctype_layout/doctype_layout.js @@ -2,31 +2,104 @@ // For license information, please see license.txt frappe.ui.form.on("DocType Layout", { - refresh: function (frm) { - frm.trigger("document_type"); - frm.events.set_button(frm); + onload_post_render(frm) { + // disallow users from manually adding/deleting rows; this doctype should only + // be used for managing layout, and docfields and custom fields should be used + // to manage other field metadata (hidden, etc.) + frm.set_df_property("fields", "cannot_add_rows", true); + frm.set_df_property("fields", "cannot_delete_rows", true); + + $(frm.wrapper).on("grid-move-row", (e, frm) => { + // refresh the layout after moving a row + frm.dirty(); + }); }, - document_type(frm) { - frm.set_fields_as_options("fields", frm.doc.document_type, null, [], "fieldname").then( - () => { - // child table empty? then show all fields as default - if (frm.doc.document_type) { - if (!(frm.doc.fields || []).length) { - for (let f of frappe.get_doc("DocType", frm.doc.document_type).fields) { - frm.add_child("fields", { fieldname: f.fieldname, label: f.label }); - } - } - } + refresh(frm) { + frm.events.add_buttons(frm); + }, + + async document_type(frm) { + if (frm.doc.document_type) { + // refreshing the doctype fields resets the new name input field; + // once the fields are set, reset the name to the original input + if (frm.is_new()) { + const document_name = frm.doc.__newname || frm.doc.name; } - ); + + frm.set_value("fields", []); + await frm.events.sync_fields(frm, false); + + if (frm.is_new()) { + frm.doc.__newname = document_name; + frm.refresh_field("__newname"); + } + } }, - set_button(frm) { + add_buttons(frm) { if (!frm.is_new()) { frm.add_custom_button(__("Go to {0} List", [frm.doc.name]), () => { window.open(`/app/${frappe.router.slug(frm.doc.name)}`); }); + + frm.add_custom_button(__("Sync {0} Fields", [frm.doc.name]), async () => { + await frm.events.sync_fields(frm, true); + }); + } + }, + + async sync_fields(frm, notify) { + frappe.dom.freeze("Fetching fields..."); + const response = await frm.call({ doc: frm.doc, method: "sync_fields" }); + frm.refresh_field("fields"); + frappe.dom.unfreeze(); + + if (!response.message) { + frappe.msgprint(__("No changes to sync")); + return; + } + + frm.dirty(); + if (notify) { + const addedFields = response.message.added; + const removedFields = response.message.removed; + + const getChangedMessage = (fields) => { + let changes = ""; + for (const field of fields) { + if (field.label) { + changes += `

  • Row #${field.idx}: ${field.fieldname.bold()} (${ + field.label + })
  • `; + } else { + changes += `
  • Row #${field.idx}: ${field.fieldname.bold()}
  • `; + } + } + return changes; + }; + + let message = ""; + + if (addedFields.length) { + message += `The following fields have been added:

    `; + } + + if (removedFields.length) { + message += `The following fields have been removed:

    `; + } + + if (message) { + frappe.msgprint({ + message: __(message), + indicator: "green", + title: __("Synced Fields"), + }); + } } }, }); diff --git a/frappe/custom/doctype/doctype_layout/doctype_layout.json b/frappe/custom/doctype/doctype_layout/doctype_layout.json index e47c9e03e0..0b627f78ce 100644 --- a/frappe/custom/doctype/doctype_layout/doctype_layout.json +++ b/frappe/custom/doctype/doctype_layout/doctype_layout.json @@ -1,7 +1,7 @@ { "actions": [], "allow_rename": 1, - "autoname": "Prompt", + "autoname": "prompt", "creation": "2020-11-16 17:05:35.306846", "doctype": "DocType", "editable_grid": 1, @@ -19,7 +19,8 @@ "in_list_view": 1, "label": "Document Type", "options": "DocType", - "reqd": 1 + "reqd": 1, + "set_only_once": 1 }, { "fieldname": "fields", @@ -42,10 +43,11 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2020-12-10 15:01:04.352184", + "modified": "2022-09-01 03:22:33.973058", "modified_by": "Administrator", "module": "Custom", "name": "DocType Layout", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { @@ -68,5 +70,6 @@ "route": "doctype-layout", "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/custom/doctype/doctype_layout/doctype_layout.py b/frappe/custom/doctype/doctype_layout/doctype_layout.py index ea8e9acc99..f712853ccd 100644 --- a/frappe/custom/doctype/doctype_layout/doctype_layout.py +++ b/frappe/custom/doctype/doctype_layout/doctype_layout.py @@ -1,11 +1,77 @@ # Copyright (c) 2020, Frappe Technologies and contributors # License: MIT. See LICENSE +from typing import TYPE_CHECKING + +import frappe from frappe.desk.utils import slug from frappe.model.document import Document +if TYPE_CHECKING: + from frappe.core.doctype.docfield.docfield import DocField + class DocTypeLayout(Document): def validate(self): if not self.route: self.route = slug(self.name) + + @frappe.whitelist() + def sync_fields(self): + doctype_fields = frappe.get_meta(self.document_type).fields + + if self.is_new(): + added_fields = [field.fieldname for field in doctype_fields] + removed_fields = [] + else: + doctype_fieldnames = {field.fieldname for field in doctype_fields} + layout_fieldnames = {field.fieldname for field in self.fields} + added_fields = list(doctype_fieldnames - layout_fieldnames) + removed_fields = list(layout_fieldnames - doctype_fieldnames) + + if not (added_fields or removed_fields): + return + + added = self.add_fields(added_fields, doctype_fields) + removed = self.remove_fields(removed_fields) + + for index, field in enumerate(self.fields): + field.idx = index + 1 + + return {"added": added, "removed": removed} + + def add_fields(self, added_fields: list[str], doctype_fields: list["DocField"]) -> list[dict]: + added = [] + for field in added_fields: + field_details = next((f for f in doctype_fields if f.fieldname == field), None) + if not field_details: + continue + + # remove 'doctype' data from the DocField to allow adding it to the layout + row = self.append("fields", field_details.as_dict(no_default_fields=True)) + row_data = row.as_dict() + + if field_details.get("insert_after"): + insert_after = next( + (f for f in self.fields if f.fieldname == field_details.insert_after), + None, + ) + + # initialize new row to just after the insert_after field + if insert_after: + self.fields.insert(insert_after.idx, row) + self.fields.pop() + + row_data = {"idx": insert_after.idx + 1, "fieldname": row.fieldname, "label": row.label} + + added.append(row_data) + return added + + def remove_fields(self, removed_fields: list[str]) -> list[dict]: + removed = [] + for field in removed_fields: + field_details = next((f for f in self.fields if f.fieldname == field), None) + if field_details: + self.remove(field_details) + removed.append(field_details.as_dict()) + return removed diff --git a/frappe/database/database.py b/frappe/database/database.py index c29861ab46..3cb47e853a 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -28,7 +28,6 @@ from frappe.database.utils import ( from frappe.exceptions import DoesNotExistError, ImplicitCommitError from frappe.model.utils.link_count import flush_local_link_count from frappe.query_builder.functions import Count -from frappe.query_builder.utils import DocType from frappe.utils import cast as cast_fieldtype from frappe.utils import get_datetime, get_table_name, getdate, now, sbool @@ -856,7 +855,7 @@ class Database: :param modified_by: Set this user as `modified_by`. :param update_modified: default True. Set as false, if you don't want to update the timestamp. :param debug: Print the query in the developer / js console. - :param for_update: Will add a row-level lock to the value that is being set so that it can be released on commit. + :param for_update: [DEPRECATED] This function now performs updates in single query, locking is not required. """ is_single_doctype = not (dn and dt != dn) to_update = field if isinstance(field, dict) else {field: val} @@ -878,19 +877,11 @@ class Database: frappe.clear_document_cache(dt, dt) else: - table = DocType(dt) - - if for_update: - docnames = tuple( - self.get_values(dt, dn, "name", debug=debug, for_update=for_update, pluck=True) - ) or (NullValue(),) - query = frappe.qb.update(table).where(table.name.isin(docnames)) - - for docname in docnames: - frappe.clear_document_cache(dt, docname) + query = frappe.qb.engine.build_conditions(table=dt, filters=dn, update=True) + if isinstance(dn, str): + frappe.clear_document_cache(dt, dn) else: - query = frappe.qb.engine.build_conditions(table=dt, filters=dn, update=True) # TODO: Fix this; doesn't work rn - gavin@frappe.io # frappe.cache().hdel_keys(dt, "document_cache") # Workaround: clear all document caches diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index 3fc241454e..1df9877eb1 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -301,6 +301,7 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database): where table_name="{table_name}" and column_name=columns.column_name and NON_UNIQUE=1 + and Seq_in_index = 1 limit 1 ), 0) as 'index', column_key = 'UNI' as 'unique' @@ -319,6 +320,37 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database): ) ) + def get_column_index( + self, table_name: str, fieldname: str, unique: bool = False + ) -> frappe._dict | None: + """Check if column exists for a specific fields in specified order. + + This differs from db.has_index because it doesn't rely on index name but columns inside an + index. + """ + + indexes = self.sql( + f"""SHOW INDEX FROM `{table_name}` + WHERE Column_name = "{fieldname}" + AND Seq_in_index = 1 + AND Non_unique={int(not unique)} + """, + as_dict=True, + ) + + # Same index can be part of clustered index which contains more fields + # We don't want those. + for index in indexes: + clustered_index = self.sql( + f"""SHOW INDEX FROM `{table_name}` + WHERE Key_name = "{index.Key_name}" + AND Seq_in_index = 2 + """, + as_dict=True, + ) + if not clustered_index: + return index + def add_index(self, doctype: str, fields: list, index_name: str = None): """Creates an index with given fields if not already created. Index name will be `fieldname1_fieldname2_index`""" diff --git a/frappe/database/mariadb/schema.py b/frappe/database/mariadb/schema.py index 8b1235d82e..5fbfc52525 100644 --- a/frappe/database/mariadb/schema.py +++ b/frappe/database/mariadb/schema.py @@ -83,45 +83,23 @@ class MariaDBTable(DBTable): for col in self.add_index: # if index key does not exists - if not frappe.db.has_index(self.table_name, col.fieldname + "_index"): + if not frappe.db.get_column_index(self.table_name, col.fieldname, unique=False): add_index_query.append(f"ADD INDEX `{col.fieldname}_index`(`{col.fieldname}`)") for col in self.drop_index + self.drop_unique: - if col.fieldname != "name": # primary key - current_column = self.current_columns.get(col.fieldname.lower()) - unique_constraint_changed = current_column.unique != col.unique - if unique_constraint_changed and not col.unique: - # nosemgrep - unique_index_record = frappe.db.sql( - """ - SHOW INDEX FROM `{}` - WHERE Key_name=%s - AND Non_unique=0 - """.format( - self.table_name - ), - (col.fieldname), - as_dict=1, - ) - if unique_index_record: - drop_index_query.append(f"DROP INDEX `{unique_index_record[0].Key_name}`") - index_constraint_changed = current_column.index != col.set_index - # if index key exists - if index_constraint_changed and not col.set_index: - # nosemgrep - index_record = frappe.db.sql( - """ - SHOW INDEX FROM `{}` - WHERE Key_name=%s - AND Non_unique=1 - """.format( - self.table_name - ), - (col.fieldname + "_index"), - as_dict=1, - ) - if index_record: - drop_index_query.append(f"DROP INDEX `{index_record[0].Key_name}`") + if col.fieldname == "name": + continue + + current_column = self.current_columns.get(col.fieldname.lower()) + unique_constraint_changed = current_column.unique != col.unique + if unique_constraint_changed and not col.unique: + if unique_index := frappe.db.get_column_index(self.table_name, col.fieldname, unique=True): + drop_index_query.append(f"DROP INDEX `{unique_index.Key_name}`") + + index_constraint_changed = current_column.index != col.set_index + if index_constraint_changed and not col.set_index: + if index_record := frappe.db.get_column_index(self.table_name, col.fieldname, unique=False): + drop_index_query.append(f"DROP INDEX `{index_record.Key_name}`") try: for query_parts in [add_column_query, modify_column_query, add_index_query, drop_index_query]: diff --git a/frappe/database/query.py b/frappe/database/query.py index cadd16297c..0cda2fdb43 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -5,6 +5,7 @@ from functools import cached_property from types import BuiltinFunctionType from typing import TYPE_CHECKING, Callable +import sqlparse from pypika.dialects import MySQLQueryBuilder, PostgreSQLQueryBuilder import frappe @@ -15,6 +16,7 @@ from frappe.query_builder import Criterion, Field, Order, Table, functions from frappe.query_builder.functions import Function, SqlFunctions from frappe.query_builder.utils import PseudoColumn from frappe.utils import cstr +from frappe.utils.data import MARIADB_SPECIFIC_COMMENT if TYPE_CHECKING: from frappe.query_builder import DocType @@ -538,7 +540,8 @@ class Engine: def get_fieldnames_from_child_table(self, doctype, fields): # Hacky and flaky implementation of implicit joins. # convert child_table.fieldname to `tabChild DocType`.`fieldname` - for idx, field in enumerate(fields, start=0): + _fields = [] + for field in fields: if "." in field and "tab" not in field: alias = None if " as " in field: @@ -552,12 +555,63 @@ class Engine: field = f"`tab{self.linked_doctype}`.`{linked_fieldname}`" if alias: field = f"{field} as {alias}" - fields[idx] = field + _fields.append(field) + return _fields + + def sanitize_fields(self, fields: str | list | tuple): + is_mariadb = frappe.db.db_type == "mariadb" + + def _sanitize_field(field: str): + if not isinstance(field, str): + return field + stripped_field = sqlparse.format(field, strip_comments=True, keyword_case="lower") + if is_mariadb: + return MARIADB_SPECIFIC_COMMENT.sub("", stripped_field) + return stripped_field + + if isinstance(fields, (list, tuple)): + return [_sanitize_field(field) for field in fields] + elif isinstance(fields, str): + return _sanitize_field(fields) + + return fields + + def get_list_fields(self, fields: list) -> list: + updated_fields = [] + if issubclass(type(fields), Criterion) or "*" in fields: + return fields + # fields = self.get_fieldnames_from_child_table(doctype=table, fields=fields) + for field in fields: + if not isinstance(field, Criterion) and field: + if " as " in field: + field, reference = field.split(" as ") + if "`" in field: + updated_fields.append(PseudoColumn(f"{field} as {reference}")) + else: + updated_fields.append(Field(field.strip()).as_(reference)) + elif "`" in str(field): + updated_fields.append(PseudoColumn(field.strip())) + else: + updated_fields.append(Field(field)) + return updated_fields + + def get_string_fields(self, fields: str) -> Field: + if fields == "*": + return fields + if "`" in fields: + fields = PseudoColumn(fields) + if " as " in str(fields): + fields, reference = str(fields).split(" as ") + if "`" in str(fields): + fields = PseudoColumn(f"{fields} as {reference}") + else: + fields = Field(fields).as_(reference) return fields def set_fields(self, fields, **kwargs) -> list: fields = kwargs.get("pluck") if kwargs.get("pluck") else fields or "name" + fields = self.sanitize_fields(fields) if isinstance(fields, list) and None in fields and Field not in fields: return None function_objects = [] @@ -581,39 +635,9 @@ class Engine: is_list, is_str = True, False if is_str: - if fields == "*": - return fields - if "`" in fields: - fields = PseudoColumn(fields) - if " as " in str(fields): - fields, reference = str(fields).split(" as ") - if "`" in str(fields): - fields = PseudoColumn(f"{fields} as {reference}") - else: - fields = Field(fields).as_(reference) - + fields = self.get_string_fields(fields) if not is_str and fields: - if issubclass(type(fields), Criterion): - return fields - updated_fields = [] - if "*" in fields: - return fields - # fields = self.get_fieldnames_from_child_table(doctype=table, fields=fields) - for field in fields: - if not isinstance(field, Criterion) and field: - if " as " in field: - field, reference = field.split(" as ") - if "`" in field: - updated_fields.append(PseudoColumn(f"{field} as {reference}")) - else: - updated_fields.append(Field(field.strip()).as_(reference)) - - elif "`" in str(field): - updated_fields.append(PseudoColumn(field.strip())) - else: - updated_fields.append(Field(field)) - - fields = updated_fields + fields = self.get_list_fields(fields) # Need to check instance again since fields modified. if not isinstance(fields, (list, tuple, set)): @@ -636,29 +660,18 @@ class Engine: and (f"`tab{table}`" not in str(field)) ): has_join = True - join_table = table_from_string(str(field)) - # check for already joined tables - if joined_tables.get("left_join") != join_table: - if self.fieldname: - criterion = criterion.left_join(join_table).on( - getattr(join_table, "name") == getattr(frappe.qb.DocType(table), self.fieldname) - ) - joined_tables["left_join"] = join_table - else: - criterion = criterion.left_join(join_table).on( - getattr(join_table, "parent") == getattr(frappe.qb.DocType(table), "name") - ) - joined_tables["left_join"] = join_table - if has_join: - for idx, field in enumerate(fields): - if not is_pypika_function_object(field): - field = field if isinstance(field, str) else field.get_sql() - if not TABLE_PATTERN.search(str(field)): - fields[idx] = getattr(frappe.qb.DocType(table), field) - else: - field.args = [getattr(frappe.qb.DocType(table), arg.get_sql()) for arg in field.args] - fields[idx] = field + if has_join: + def _update_pypika_fields(field): + if not is_pypika_function_object(field): + field = field if isinstance(field, str) else field.get_sql() + if not TABLE_PATTERN.search(str(field)): + return getattr(frappe.qb.DocType(table), field) + else: + field.args = [getattr(frappe.qb.DocType(table), arg.get_sql()) for arg in field.args] + return field + + fields = [_update_pypika_fields(field) for field in fields] if len(self.tables) > 1: primary_table = self.tables.pop(table) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index a85f269c8f..8d2f1df973 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -215,14 +215,13 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date): group_by="_unit", order_by="_unit asc", as_list=True, - ignore_ifnull=True, ) result = get_result(data, timegrain, from_date, to_date, chart.chart_type) return { "labels": [ - format_date(get_period(r[0], timegrain)) + format_date(get_period(r[0], timegrain), parse_day_first=True) if timegrain in ("Daily", "Weekly") else get_period(r[0], timegrain) for r in result @@ -294,13 +293,6 @@ def get_group_by_chart_config(chart, filters): ) if data: - if chart.number_of_groups and chart.number_of_groups < len(data): - other_count = 0 - for i in range(chart.number_of_groups - 1, len(data)): - other_count += data[i]["count"] - data = data[0 : chart.number_of_groups - 1] - data.append({"name": "Other", "count": other_count}) - chart_config = { "labels": [item["name"] if item["name"] else "Not Specified" for item in data], "datasets": [{"name": chart.name, "values": [item["count"] for item in data]}], diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py index 820f3c0555..ddbabedcb4 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -173,7 +173,7 @@ class TestDashboardChart(FrappeTestCase): self.assertEqual(result.get("datasets")[0].get("values"), [200.0, 400.0, 300.0, 0.0, 100.0, 0.0]) self.assertEqual( result.get("labels"), - ["06-01-2019", "07-01-2019", "08-01-2019", "09-01-2019", "10-01-2019", "11-01-2019"], + ["01-06-2019", "01-07-2019", "01-08-2019", "01-09-2019", "01-10-2019", "01-11-2019"], ) def test_weekly_dashboard_chart(self): @@ -203,7 +203,7 @@ class TestDashboardChart(FrappeTestCase): result = get(chart_name="Test Weekly Dashboard Chart", refresh=1) self.assertEqual(result.get("datasets")[0].get("values"), [50.0, 300.0, 800.0, 0.0]) - self.assertEqual(result.get("labels"), ["12-30-2018", "06-01-2019", "01-13-2019", "01-20-2019"]) + self.assertEqual(result.get("labels"), ["12-30-2018", "01-06-2019", "01-13-2019", "01-20-2019"]) def test_avg_dashboard_chart(self): insert_test_records() @@ -230,7 +230,7 @@ class TestDashboardChart(FrappeTestCase): with patch.object(frappe.utils.data, "get_first_day_of_the_week", return_value="Monday"): result = get(chart_name="Test Average Dashboard Chart", refresh=1) - self.assertEqual(result.get("labels"), ["12-30-2018", "06-01-2019", "01-13-2019", "01-20-2019"]) + self.assertEqual(result.get("labels"), ["12-30-2018", "01-06-2019", "01-13-2019", "01-20-2019"]) self.assertEqual(result.get("datasets")[0].get("values"), [50.0, 150.0, 266.6666666666667, 0.0]) def test_user_date_label_dashboard_chart(self): @@ -255,13 +255,13 @@ class TestDashboardChart(FrappeTestCase): with patch.object(frappe.utils.data, "get_user_date_format", return_value="dd.mm.yyyy"): result = get(chart_name="Test Dashboard Chart Date Label") self.assertEqual( - sorted(result.get("labels")), sorted(["01.05.2019", "01.12.2019", "19.01.2019"]) + sorted(result.get("labels")), sorted(["05.01.2019", "12.01.2019", "19.01.2019"]) ) with patch.object(frappe.utils.data, "get_user_date_format", return_value="mm-dd-yyyy"): result = get(chart_name="Test Dashboard Chart Date Label") self.assertEqual( - sorted(result.get("labels")), sorted(["01-19-2019", "05-01-2019", "12-01-2019"]) + sorted(result.get("labels")), sorted(["01-19-2019", "01-05-2019", "01-12-2019"]) ) diff --git a/frappe/desk/doctype/event/event.js b/frappe/desk/doctype/event/event.js index 61bf66f5e5..299cbe5cc3 100644 --- a/frappe/desk/doctype/event/event.js +++ b/frappe/desk/doctype/event/event.js @@ -41,6 +41,18 @@ frappe.ui.form.on("Event", { }, __("Add Participants") ); + + const [ends_on_date] = frm.doc.ends_on + ? frm.doc.ends_on.split(" ") + : frm.doc.starts_on.split(" "); + + if (frm.doc.google_meet_link && frappe.datetime.now_date() <= ends_on_date) { + frm.dashboard.set_headline( + __("Join video conference with {0}", [ + `Google Meet`, + ]) + ); + } }, repeat_on: function (frm) { if (frm.doc.repeat_on === "Every Day") { diff --git a/frappe/desk/doctype/event/event.json b/frappe/desk/doctype/event/event.json index cb2e42aab2..5ca49f3831 100644 --- a/frappe/desk/doctype/event/event.json +++ b/frappe/desk/doctype/event/event.json @@ -22,12 +22,14 @@ "sender", "all_day", "sync_with_google_calendar", + "add_video_conferencing", "sb_00", "google_calendar", - "pulled_from_google_calendar", - "cb_00", "google_calendar_id", + "cb_00", "google_calendar_event_id", + "google_meet_link", + "pulled_from_google_calendar", "section_break_13", "repeat_on", "repeat_till", @@ -225,7 +227,7 @@ }, { "collapsible": 1, - "depends_on": "eval:doc.sync_with_google_calendar", + "depends_on": "eval:doc.sync_with_google_calendar || doc.pulled_from_google_calendar", "fieldname": "sb_00", "fieldtype": "Section Break", "label": "Google Calendar" @@ -245,6 +247,7 @@ "fieldname": "google_calendar_event_id", "fieldtype": "Data", "label": "Google Calendar Event ID", + "no_copy": 1, "read_only": 1 }, { @@ -272,12 +275,27 @@ "label": "Sender", "options": "Email", "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:doc.sync_with_google_calendar", + "description": "via Google Meet", + "fieldname": "add_video_conferencing", + "fieldtype": "Check", + "label": "Add Video Conferencing" + }, + { + "fieldname": "google_meet_link", + "fieldtype": "Data", + "label": "Google Meet Link", + "no_copy": 1, + "read_only": 1 } ], "icon": "fa fa-calendar", "idx": 1, "links": [], - "modified": "2022-05-12 05:43:27.935510", + "modified": "2022-08-12 19:24:34.794098", "modified_by": "Administrator", "module": "Desk", "name": "Event", diff --git a/frappe/desk/doctype/event/event.py b/frappe/desk/doctype/event/event.py index 7bcb8207a7..fafd317155 100644 --- a/frappe/desk/doctype/event/event.py +++ b/frappe/desk/doctype/event/event.py @@ -6,6 +6,7 @@ import json import frappe from frappe import _ +from frappe.contacts.doctype.contact.contact import get_default_contact from frappe.desk.doctype.notification_settings.notification_settings import ( is_email_notifications_enabled_for_type, ) @@ -55,6 +56,12 @@ class Event(Document): if self.sync_with_google_calendar and not self.google_calendar: frappe.throw(_("Select Google Calendar to which event should be synced.")) + if not self.sync_with_google_calendar: + self.add_video_conferencing = 0 + + def before_save(self): + self.set_participants_email() + def on_update(self): self.sync_communication() @@ -131,6 +138,22 @@ class Event(Document): for participant in participants: self.add_participant(participant["doctype"], participant["docname"]) + def set_participants_email(self): + for participant in self.event_participants: + if participant.email: + continue + + if participant.reference_doctype != "Contact": + participant_contact = get_default_contact( + participant.reference_doctype, participant.reference_docname + ) + else: + participant_contact = participant.reference_docname + + participant.email = ( + frappe.get_value("Contact", participant_contact, "email_id") if participant_contact else None + ) + @frappe.whitelist() def delete_communication(event, reference_doctype, reference_docname): diff --git a/frappe/desk/doctype/event_participants/event_participants.json b/frappe/desk/doctype/event_participants/event_participants.json index 1b40e7042b..bbb0a24f3e 100644 --- a/frappe/desk/doctype/event_participants/event_participants.json +++ b/frappe/desk/doctype/event_participants/event_participants.json @@ -6,7 +6,8 @@ "engine": "InnoDB", "field_order": [ "reference_doctype", - "reference_docname" + "reference_docname", + "email" ], "fields": [ { @@ -24,11 +25,17 @@ "label": "Reference Name", "options": "reference_doctype", "reqd": 1 + }, + { + "fieldname": "email", + "fieldtype": "Data", + "label": "Email", + "options": "Email" } ], "istable": 1, "links": [], - "modified": "2022-08-03 12:20:50.466370", + "modified": "2022-10-18 17:49:33.549459", "modified_by": "Administrator", "module": "Desk", "name": "Event Participants", diff --git a/frappe/desk/doctype/notification_log/notification_log.py b/frappe/desk/doctype/notification_log/notification_log.py index b46795dd8a..4d82932555 100644 --- a/frappe/desk/doctype/notification_log/notification_log.py +++ b/frappe/desk/doctype/notification_log/notification_log.py @@ -20,6 +20,14 @@ class NotificationLog(Document): except frappe.OutgoingEmailError: self.log_error(_("Failed to send notification email")) + @staticmethod + def clear_old_logs(days=180): + from frappe.query_builder import Interval + from frappe.query_builder.functions import Now + + table = frappe.qb.DocType("Notification Log") + frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days)))) + def get_permission_query_conditions(for_user): if not for_user: diff --git a/frappe/desk/doctype/notification_log/notification_log_list.js b/frappe/desk/doctype/notification_log/notification_log_list.js new file mode 100644 index 0000000000..150ffabfa7 --- /dev/null +++ b/frappe/desk/doctype/notification_log/notification_log_list.js @@ -0,0 +1,7 @@ +frappe.listview_settings["Notification Log"] = { + onload: function (listview) { + frappe.require("logtypes.bundle.js", () => { + frappe.utils.logtypes.show_log_retention_message(cur_list.doctype); + }); + }, +}; diff --git a/frappe/desk/form/save.py b/frappe/desk/form/save.py index f3e7b6294f..d709f7b592 100644 --- a/frappe/desk/form/save.py +++ b/frappe/desk/form/save.py @@ -5,6 +5,7 @@ import json import frappe from frappe.desk.form.load import run_onload +from frappe.monitor import add_data_to_monitor @frappe.whitelist() @@ -25,6 +26,8 @@ def savedocs(doc, action): run_onload(doc) send_updated_docs(doc) + add_data_to_monitor(doctype=doc.doctype, action=action) + frappe.msgprint(frappe._("Saved"), indicator="green", alert=True) diff --git a/frappe/desk/moduleview.py b/frappe/desk/moduleview.py deleted file mode 100644 index 913b3406e3..0000000000 --- a/frappe/desk/moduleview.py +++ /dev/null @@ -1,615 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: MIT. See LICENSE - -import json - -import frappe -from frappe import _ -from frappe.boot import get_allowed_pages, get_allowed_reports -from frappe.cache_manager import ( - build_domain_restriced_doctype_cache, - build_domain_restriced_page_cache, - build_table_count_cache, -) -from frappe.desk.doctype.desktop_icon.desktop_icon import clear_desktop_icons_cache, set_hidden - - -@frappe.whitelist() -def get(module): - """Returns data (sections, list of reports, counts) to render module view in desk: - `/desk/#Module/[name]`.""" - data = get_data(module) - - out = {"data": data} - - return out - - -@frappe.whitelist() -def hide_module(module): - set_hidden(module, frappe.session.user, 1) - clear_desktop_icons_cache() - - -def get_table_with_counts(): - counts = frappe.cache().get_value("information_schema:counts") - if counts: - return counts - else: - return build_table_count_cache() - - -def get_data(module, build=True): - """Get module data for the module view `desk/#Module/[name]`""" - doctype_info = get_doctype_info(module) - data = build_config_from_file(module) - - if not data: - data = build_standard_config(module, doctype_info) - else: - add_custom_doctypes(data, doctype_info) - - add_section(data, _("Custom Reports"), "fa fa-list-alt", get_report_list(module)) - - data = combine_common_sections(data) - data = apply_permissions(data) - - # set_last_modified(data) - - if build: - exists_cache = get_table_with_counts() - - def doctype_contains_a_record(name): - exists = exists_cache.get(name) - if not exists: - if not frappe.db.get_value("DocType", name, "issingle"): - exists = frappe.db.count(name) - else: - exists = True - exists_cache[name] = exists - return exists - - for section in data: - for item in section["items"]: - # Onboarding - - # First disable based on exists of depends_on list - doctype = item.get("doctype") - dependencies = item.get("dependencies") or None - if not dependencies and doctype: - item["dependencies"] = [doctype] - - dependencies = item.get("dependencies") - if dependencies: - incomplete_dependencies = [d for d in dependencies if not doctype_contains_a_record(d)] - if len(incomplete_dependencies): - item["incomplete_dependencies"] = incomplete_dependencies - - if item.get("onboard"): - # Mark Spotlights for initial - if item.get("type") == "doctype": - name = item.get("name") - count = doctype_contains_a_record(name) - - item["count"] = count - - return data - - -def build_config_from_file(module): - """Build module info from `app/config/desktop.py` files.""" - data = [] - module = frappe.scrub(module) - - for app in frappe.get_installed_apps(): - try: - data += get_config(app, module) - except ImportError: - pass - - return filter_by_restrict_to_domain(data) - - -def filter_by_restrict_to_domain(data): - """filter Pages and DocType depending on the Active Module(s)""" - doctypes = ( - frappe.cache().get_value("domain_restricted_doctypes") or build_domain_restriced_doctype_cache() - ) - pages = frappe.cache().get_value("domain_restricted_pages") or build_domain_restriced_page_cache() - - for d in data: - _items = [] - for item in d.get("items", []): - - item_type = item.get("type") - item_name = item.get("name") - - if (item_name in pages) or (item_name in doctypes) or item_type == "report": - _items.append(item) - - d.update({"items": _items}) - - return data - - -def build_standard_config(module, doctype_info): - """Build standard module data from DocTypes.""" - if not frappe.db.get_value("Module Def", module): - frappe.throw(_("Module Not Found")) - - data = [] - - add_section( - data, - _("Documents"), - "fa fa-star", - [d for d in doctype_info if d.document_type in ("Document", "Transaction")], - ) - - add_section( - data, - _("Setup"), - "fa fa-cog", - [d for d in doctype_info if d.document_type in ("Master", "Setup", "")], - ) - - add_section(data, _("Standard Reports"), "fa fa-list", get_report_list(module, is_standard="Yes")) - - return data - - -def add_section(data, label, icon, items): - """Adds a section to the module data.""" - if not items: - return - data.append({"label": label, "icon": icon, "items": items}) - - -def add_custom_doctypes(data, doctype_info): - """Adds Custom DocTypes to modules setup via `config/desktop.py`.""" - add_section( - data, - _("Documents"), - "fa fa-star", - [d for d in doctype_info if (d.custom and d.document_type in ("Document", "Transaction"))], - ) - - add_section( - data, - _("Setup"), - "fa fa-cog", - [d for d in doctype_info if (d.custom and d.document_type in ("Setup", "Master", ""))], - ) - - -def get_doctype_info(module): - """Returns list of non child DocTypes for given module.""" - active_domains = frappe.get_active_domains() - - doctype_info = frappe.get_all( - "DocType", - filters={"module": module, "istable": 0}, - or_filters={"ifnull(restrict_to_domain, '')": "", "restrict_to_domain": ("in", active_domains)}, - fields=["'doctype' as type", "name", "description", "document_type", "custom", "issingle"], - order_by="custom asc, document_type desc, name asc", - ) - - for d in doctype_info: - d.document_type = d.document_type or "" - d.description = _(d.description or "") - - return doctype_info - - -def combine_common_sections(data): - """Combine sections declared in separate apps.""" - sections = [] - sections_dict = {} - for each in data: - if each["label"] not in sections_dict: - sections_dict[each["label"]] = each - sections.append(each) - else: - sections_dict[each["label"]]["items"] += each["items"] - - return sections - - -def apply_permissions(data): - default_country = frappe.db.get_default("country") - - user = frappe.get_user() - user.build_permissions() - - allowed_pages = get_allowed_pages() - allowed_reports = get_allowed_reports() - - new_data = [] - for section in data: - new_items = [] - - for item in section.get("items") or []: - item = frappe._dict(item) - - if item.country and item.country != default_country: - continue - - if ( - (item.type == "doctype" and item.name in user.can_read) - or (item.type == "page" and item.name in allowed_pages) - or (item.type == "report" and item.name in allowed_reports) - or item.type == "help" - ): - - new_items.append(item) - - if new_items: - new_section = section.copy() - new_section["items"] = new_items - new_data.append(new_section) - - return new_data - - -def get_disabled_reports(): - if not hasattr(frappe.local, "disabled_reports"): - frappe.local.disabled_reports = {r.name for r in frappe.get_all("Report", {"disabled": 1})} - return frappe.local.disabled_reports - - -def get_config(app, module): - """Load module info from `[app].config.[module]`.""" - config = frappe.get_module(f"{app}.config.{module}") - config = config.get_data() - - sections = [s for s in config if s.get("condition", True)] - - disabled_reports = get_disabled_reports() - for section in sections: - items = [] - for item in section["items"]: - if item["type"] == "report" and item["name"] in disabled_reports: - continue - # some module links might not have name - if not item.get("name"): - item["name"] = item.get("label") - if not item.get("label"): - item["label"] = _(item.get("name")) - items.append(item) - section["items"] = items - - return sections - - -def config_exists(app, module): - try: - frappe.get_module(f"{app}.config.{module}") - return True - except ImportError: - return False - - -def add_setup_section(config, app, module, label, icon): - """Add common sections to `/desk#Module/Setup`""" - try: - setup_section = get_setup_section(app, module, label, icon) - if setup_section: - config.append(setup_section) - except ImportError: - pass - - -def get_setup_section(app, module, label, icon): - """Get the setup section from each module (for global Setup page).""" - config = get_config(app, module) - for section in config: - if section.get("label") == _("Setup"): - return {"label": label, "icon": icon, "items": section["items"]} - - -def get_onboard_items(app, module): - try: - sections = get_config(app, module) - except ImportError: - return [] - - onboard_items = [] - fallback_items = [] - - if not sections: - doctype_info = get_doctype_info(module) - sections = build_standard_config(module, doctype_info) - - for section in sections: - for item in section["items"]: - if item.get("onboard", 0) == 1: - onboard_items.append(item) - - # in case onboard is not set - fallback_items.append(item) - - if len(onboard_items) > 5: - return onboard_items - - return onboard_items or fallback_items - - -@frappe.whitelist() -def get_links_for_module(app, module): - return [{"value": l.get("name"), "label": l.get("label")} for l in get_links(app, module)] - - -def get_links(app, module): - try: - sections = get_config(app, frappe.scrub(module)) - except ImportError: - return [] - - links = [] - for section in sections: - for item in section["items"]: - links.append(item) - return links - - -@frappe.whitelist() -def get_desktop_settings(): - from frappe.config import get_modules_from_all_apps_for_user - - all_modules = get_modules_from_all_apps_for_user() - home_settings = get_home_settings() - - modules_by_name = {} - for m in all_modules: - modules_by_name[m["module_name"]] = m - - module_categories = ["Modules", "Domains", "Places", "Administration"] - user_modules_by_category = {} - - user_saved_modules_by_category = home_settings.modules_by_category or {} - user_saved_links_by_module = home_settings.links_by_module or {} - - def apply_user_saved_links(module): - module = frappe._dict(module) - all_links = get_links(module.app, module.module_name) - module_links_by_name = {} - for link in all_links: - module_links_by_name[link["name"]] = link - - if module.module_name in user_saved_links_by_module: - user_links = frappe.parse_json(user_saved_links_by_module[module.module_name]) - module.links = [module_links_by_name[l] for l in user_links if l in module_links_by_name] - - return module - - for category in module_categories: - if category in user_saved_modules_by_category: - user_modules = user_saved_modules_by_category[category] - user_modules_by_category[category] = [ - apply_user_saved_links(modules_by_name[m]) for m in user_modules if modules_by_name.get(m) - ] - else: - user_modules_by_category[category] = [ - apply_user_saved_links(m) for m in all_modules if m.get("category") == category - ] - - # filter out hidden modules - if home_settings.hidden_modules: - for category in user_modules_by_category: - hidden_modules = home_settings.hidden_modules or [] - modules = user_modules_by_category[category] - user_modules_by_category[category] = [ - module for module in modules if module.module_name not in hidden_modules - ] - - return user_modules_by_category - - -@frappe.whitelist() -def update_hidden_modules(category_map): - category_map = frappe.parse_json(category_map) - home_settings = get_home_settings() - - saved_hidden_modules = home_settings.hidden_modules or [] - - for category in category_map: - config = frappe._dict(category_map[category]) - saved_hidden_modules += config.removed or [] - saved_hidden_modules = [d for d in saved_hidden_modules if d not in (config.added or [])] - - if home_settings.get("modules_by_category") and home_settings.modules_by_category.get(category): - module_placement = [ - d for d in (config.added or []) if d not in home_settings.modules_by_category[category] - ] - home_settings.modules_by_category[category] += module_placement - - home_settings.hidden_modules = saved_hidden_modules - set_home_settings(home_settings) - - return get_desktop_settings() - - -@frappe.whitelist() -def update_global_hidden_modules(modules): - modules = frappe.parse_json(modules) - frappe.only_for("System Manager") - - doc = frappe.get_doc("User", "Administrator") - doc.set("block_modules", []) - for module in modules: - doc.append("block_modules", {"module": module}) - - doc.save(ignore_permissions=True) - - return get_desktop_settings() - - -@frappe.whitelist() -def update_modules_order(module_category, modules): - modules = frappe.parse_json(modules) - home_settings = get_home_settings() - - home_settings.modules_by_category = home_settings.modules_by_category or {} - home_settings.modules_by_category[module_category] = modules - - set_home_settings(home_settings) - - -@frappe.whitelist() -def update_links_for_module(module_name, links): - links = frappe.parse_json(links) - home_settings = get_home_settings() - - home_settings.setdefault("links_by_module", {}) - home_settings["links_by_module"].setdefault(module_name, None) - home_settings["links_by_module"][module_name] = links - - set_home_settings(home_settings) - - return get_desktop_settings() - - -@frappe.whitelist() -def get_options_for_show_hide_cards(): - global_options = [] - - if "System Manager" in frappe.get_roles(): - global_options = get_options_for_global_modules() - - return {"user_options": get_options_for_user_blocked_modules(), "global_options": global_options} - - -@frappe.whitelist() -def get_options_for_global_modules(): - from frappe.config import get_modules_from_all_apps - - all_modules = get_modules_from_all_apps() - - blocked_modules = frappe.get_doc("User", "Administrator").get_blocked_modules() - - options = [] - for module in all_modules: - module = frappe._dict(module) - options.append( - { - "category": module.category, - "label": module.label, - "value": module.module_name, - "checked": module.module_name not in blocked_modules, - } - ) - - return options - - -@frappe.whitelist() -def get_options_for_user_blocked_modules(): - from frappe.config import get_modules_from_all_apps_for_user - - all_modules = get_modules_from_all_apps_for_user() - home_settings = get_home_settings() - - hidden_modules = home_settings.hidden_modules or [] - - options = [] - for module in all_modules: - module = frappe._dict(module) - options.append( - { - "category": module.category, - "label": module.label, - "value": module.module_name, - "checked": module.module_name not in hidden_modules, - } - ) - - return options - - -def set_home_settings(home_settings): - frappe.cache().hset("home_settings", frappe.session.user, home_settings) - frappe.db.set_value("User", frappe.session.user, "home_settings", json.dumps(home_settings)) - - -@frappe.whitelist() -def get_home_settings(): - def get_from_db(): - settings = frappe.db.get_value("User", frappe.session.user, "home_settings") - return frappe.parse_json(settings or "{}") - - home_settings = frappe.cache().hget("home_settings", frappe.session.user, get_from_db) - return home_settings - - -def get_module_link_items_from_list(app, module, list_of_link_names): - try: - sections = get_config(app, frappe.scrub(module)) - except ImportError: - return [] - - links = [] - for section in sections: - for item in section["items"]: - if item.get("label", "") in list_of_link_names: - links.append(item) - - return links - - -def set_last_modified(data): - for section in data: - for item in section["items"]: - if item["type"] == "doctype": - item["last_modified"] = get_last_modified(item["name"]) - - -def get_last_modified(doctype): - def _get(): - try: - last_modified = frappe.get_all( - doctype, fields=["max(modified)"], as_list=True, limit_page_length=1 - )[0][0] - except Exception as e: - if frappe.db.is_table_missing(e): - last_modified = None - else: - raise - - # hack: save as -1 so that it is cached - if last_modified is None: - last_modified = -1 - - return last_modified - - last_modified = frappe.cache().hget("last_modified", doctype, _get) - - if last_modified == -1: - last_modified = None - - return last_modified - - -def get_report_list(module, is_standard="No"): - """Returns list on new style reports for modules.""" - reports = frappe.get_list( - "Report", - fields=["name", "ref_doctype", "report_type"], - filters={"is_standard": is_standard, "disabled": 0, "module": module}, - order_by="name", - ) - - out = [] - for r in reports: - out.append( - { - "type": "report", - "doctype": r.ref_doctype, - "is_query_report": 1 - if r.report_type in ("Query Report", "Script Report", "Custom Report") - else 0, - "label": _(r.name), - "name": r.name, - } - ) - - return out diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py index 3422602720..408776fcb9 100755 --- a/frappe/desk/page/setup_wizard/setup_wizard.py +++ b/frappe/desk/page/setup_wizard/setup_wizard.py @@ -2,11 +2,10 @@ # License: MIT. See LICENSE import json -import os import frappe from frappe.geo.country_info import get_country_info -from frappe.translate import get_dict, send_translations, set_default_language +from frappe.translate import get_messages_for_boot, send_translations, set_default_language from frappe.utils import cint, strip from frappe.utils.password import update_password @@ -290,15 +289,7 @@ def load_messages(language): frappe.clear_cache() set_default_language(get_language_code(language)) frappe.db.commit() - m = get_dict("page", "setup-wizard") - - for path in frappe.get_hooks("setup_wizard_requires"): - # common folder `assets` served from `sites/` - js_file_path = os.path.abspath(frappe.get_site_path("..", *path.strip("/").split("/"))) - m.update(get_dict("jsfile", js_file_path)) - - m.update(get_dict("boot")) - send_translations(m) + send_translations(get_messages_for_boot()) return frappe.local.lang diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index e88a453e64..877fdbe5bc 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -12,6 +12,7 @@ from frappe import _ from frappe.core.utils import ljust_list from frappe.model.utils import render_include from frappe.modules import get_module_path, scrub +from frappe.monitor import add_data_to_monitor from frappe.permissions import get_role_permissions from frappe.translate import send_translations from frappe.utils import ( @@ -251,6 +252,7 @@ def run( result = get_prepared_report_result(report, filters, dn, user) else: result = generate_report_result(report, filters, user, custom_columns, is_tree, parent_field) + add_data_to_monitor(report=report.reference_report or report.name) result["add_total_row"] = report.add_total_row and not result.get("skip_total_row", False) diff --git a/frappe/desk/report_dump.py b/frappe/desk/report_dump.py deleted file mode 100644 index 6650d24757..0000000000 --- a/frappe/desk/report_dump.py +++ /dev/null @@ -1,107 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: MIT. See LICENSE - - -import copy -import json - -import frappe - - -@frappe.whitelist() -def get_data(doctypes, last_modified): - data_map = {} - for dump_report_map in frappe.get_hooks().dump_report_map: - data_map.update(frappe.get_attr(dump_report_map)) - - out = {} - - doctypes = json.loads(doctypes) - last_modified = json.loads(last_modified) - - for d in doctypes: - args = copy.deepcopy(data_map[d]) - dt = d.find("[") != -1 and d[: d.find("[")] or d - out[dt] = {} - - if args.get("from"): - modified_table = "item." - else: - modified_table = "" - - conditions = order_by = "" - table = args.get("from") or ("`tab%s`" % dt) - - if d in last_modified: - if not args.get("conditions"): - args["conditions"] = [] - args["conditions"].append(modified_table + "modified > '" + last_modified[d] + "'") - out[dt]["modified_names"] = frappe.db.sql_list( - """select %sname from %s - where %smodified > %s""" - % (modified_table, table, modified_table, "%s"), - last_modified[d], - ) - - if args.get("force_index"): - conditions = " force index (%s) " % args["force_index"] - if args.get("conditions"): - conditions += " where " + " and ".join(args["conditions"]) - if args.get("order_by"): - order_by = " order by " + args["order_by"] - - out[dt]["data"] = [ - list(t) - for t in frappe.db.sql( - """select {} from {} {} {}""".format(",".join(args["columns"]), table, conditions, order_by) - ) - ] - - # last modified - modified_table = table - if "," in table: - modified_table = " ".join(table.split(",")[0].split(" ")[:-1]) - - tmp = frappe.db.sql( - """select `modified` - from %s order by modified desc limit 1""" - % modified_table - ) - out[dt]["last_modified"] = tmp and tmp[0][0] or "" - out[dt]["columns"] = list(map(lambda c: c.split(" as ")[-1], args["columns"])) - - if args.get("links"): - out[dt]["links"] = args["links"] - - for d in out: - unused_links = [] - # only compress full dumps (not partial) - if out[d].get("links") and (d not in last_modified): - for link_key in out[d]["links"]: - link = out[d]["links"][link_key] - if link[0] in out and (link[0] not in last_modified): - - # make a map of link ids - # to index - link_map = {} - doctype_data = out[link[0]] - - col_idx = doctype_data["columns"].index(link[1]) - for row_idx in range(len(doctype_data["data"])): - row = doctype_data["data"][row_idx] - link_map[row[col_idx]] = row_idx - - for row in out[d]["data"]: - columns = list(out[d]["columns"]) - if link_key in columns: - col_idx = columns.index(link_key) - # replace by id - if row[col_idx]: - row[col_idx] = link_map.get(row[col_idx]) - else: - unused_links.append(link_key) - - for link in unused_links: - del out[d]["links"][link] - - return out diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 53a8c39ee5..c4a94074ba 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -8,7 +8,6 @@ import re import frappe from frappe import _, is_whitelisted from frappe.permissions import has_permission -from frappe.translate import get_translated_doctypes from frappe.utils import cint, cstr, unique @@ -150,10 +149,6 @@ def search_widget( filters = [] or_filters = [] - translated_doctypes = frappe.cache().hget( - "translated_doctypes", "doctypes", get_translated_doctypes - ) - # build from doctype if txt: field_types = [ @@ -175,7 +170,7 @@ def search_widget( for f in search_fields: fmeta = meta.get_field(f.strip()) - if (doctype not in translated_doctypes) and ( + if not meta.translated_doctype and ( f == "name" or (fmeta and fmeta.fieldtype in field_types) ): or_filters.append([doctype, f.strip(), "like", f"%{txt}%"]) @@ -191,26 +186,25 @@ def search_widget( fields = list(set(fields + json.loads(filter_fields))) formatted_fields = [f"`tab{meta.name}`.`{f.strip()}`" for f in fields] - title_field_query = get_title_field_query(meta) - # Insert title field query after name - if title_field_query: - formatted_fields.insert(1, title_field_query) - - # find relevance as location of search term from the beginning of string `name`. used for sorting results. - formatted_fields.append( - """locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format( - _txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")), - doctype=doctype, - ) - ) + if meta.show_title_field_in_link: + formatted_fields.insert(1, f"`tab{meta.name}`.{meta.title_field} as `label`") # In order_by, `idx` gets second priority, because it stores link count from frappe.model.db_query import get_order_by order_by_based_on_meta = get_order_by(doctype, meta) # 2 is the index of _relevance column - order_by = f"_relevance, {order_by_based_on_meta}, `tab{doctype}`.idx desc" + order_by = f"{order_by_based_on_meta}, `tab{doctype}`.idx desc" + + if not meta.translated_doctype: + formatted_fields.append( + """locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format( + _txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")), + doctype=doctype, + ) + ) + order_by = f"_relevance, {order_by}" ptype = "select" if frappe.only_has_select_perm(doctype) else "read" ignore_permissions = ( @@ -219,16 +213,13 @@ def search_widget( else (cint(ignore_user_permissions) and has_permission(doctype, ptype=ptype)) ) - if doctype in translated_doctypes: - page_length = None - values = frappe.get_list( doctype, filters=filters, fields=formatted_fields, or_filters=or_filters, limit_start=start, - limit_page_length=page_length, + limit_page_length=None if meta.translated_doctype else page_length, order_by=order_by, ignore_permissions=ignore_permissions, reference_doctype=reference_doctype, @@ -236,12 +227,15 @@ def search_widget( strict=False, ) - if doctype in translated_doctypes: + if meta.translated_doctype: # Filtering the values array so that query is included in very element values = ( - v - for v in values - if re.search(f"{re.escape(txt)}.*", _(v.name if as_dict else v[0]), re.IGNORECASE) + result + for result in values + if any( + re.search(f"{re.escape(txt)}.*", _(cstr(value)) or "", re.IGNORECASE) + for value in (result.values() if as_dict else result) + ) ) # Sorting the values array so that relevant results always come first @@ -250,12 +244,14 @@ def search_widget( values = sorted(values, key=lambda x: relevance_sorter(x, txt, as_dict)) # remove _relevance from results - if as_dict: - for r in values: - r.pop("_relevance") - frappe.response["values"] = values - else: - frappe.response["values"] = [r[:-1] for r in values] + if not meta.translated_doctype: + if as_dict: + for r in values: + r.pop("_relevance") + else: + values = [r[:-1] for r in values] + + frappe.response["values"] = values def get_std_fields_list(meta, key): @@ -275,39 +271,23 @@ def get_std_fields_list(meta, key): return sflist -def get_title_field_query(meta): - title_field = meta.title_field if meta.title_field else None - show_title_field_in_link = ( - meta.show_title_field_in_link if meta.show_title_field_in_link else None - ) - field = None +def build_for_autosuggest(res: list[tuple], doctype: str) -> list[dict]: + def to_string(parts): + return ", ".join( + unique(_(cstr(part)) if meta.translated_doctype else cstr(part) for part in parts if part) + ) - if title_field and show_title_field_in_link: - field = f"`tab{meta.name}`.{title_field} as `label`" - - return field - - -def build_for_autosuggest(res, doctype): results = [] meta = frappe.get_meta(doctype) - if not (meta.title_field and meta.show_title_field_in_link): - for r in res: - r = list(r) - results.append({"value": r[0], "description": ", ".join(unique(cstr(d) for d in r[1:] if d))}) - + if meta.show_title_field_in_link: + for item in res: + item = list(item) + label = item[1] # use title as label + item[1] = item[0] # show name in description instead of title + del item[2] # remove redundant title ("label") value + results.append({"value": item[0], "label": label, "description": to_string(item[1:])}) else: - title_field_exists = meta.title_field and meta.show_title_field_in_link - _from = 2 if title_field_exists else 1 # to exclude title from description if title_field_exists - for r in res: - r = list(r) - results.append( - { - "value": r[0], - "label": r[1] if title_field_exists else None, - "description": ", ".join(unique(cstr(d) for d in r[_from:] if d)), - } - ) + results.extend({"value": item[0], "description": to_string(item[1:])} for item in res) return results @@ -383,7 +363,7 @@ def get_user_groups(): def get_link_title(doctype, docname): meta = frappe.get_meta(doctype) - if meta.title_field and meta.show_title_field_in_link: + if meta.show_title_field_in_link: return frappe.db.get_value(doctype, docname, meta.title_field) return docname diff --git a/frappe/email/doctype/auto_email_report/auto_email_report.py b/frappe/email/doctype/auto_email_report/auto_email_report.py index 7b18c8632b..60a69607a2 100644 --- a/frappe/email/doctype/auto_email_report/auto_email_report.py +++ b/frappe/email/doctype/auto_email_report/auto_email_report.py @@ -40,8 +40,7 @@ class AutoEmailReport(Document): @property def sender_email(self): - email_id, login_id = frappe.db.get_value("Email Account", self.sender, ["email_id", "login_id"]) - return login_id if login_id else email_id + return frappe.db.get_value("Email Account", self.sender, "email_id") def validate_emails(self): """Cleanup list of emails""" diff --git a/frappe/email/doctype/notification/notification.js b/frappe/email/doctype/notification/notification.js index a07dca4870..a149aacd57 100644 --- a/frappe/email/doctype/notification/notification.js +++ b/frappe/email/doctype/notification/notification.js @@ -176,7 +176,7 @@ frappe.ui.form.on("Notification", { }, callback: function (r) { if (r.message && r.message.length > 0) { - frappe.msgprint(r.message); + frappe.msgprint(r.message.toString()); } else { frappe.msgprint(__("No alerts for today")); } diff --git a/frappe/hooks.py b/frappe/hooks.py index 9715508c78..73327f0ab1 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -222,6 +222,7 @@ scheduler_events = { "frappe.email.doctype.unhandled_email.unhandled_email.remove_old_unhandled_emails", "frappe.core.doctype.prepared_report.prepared_report.delete_expired_prepared_reports", "frappe.core.doctype.log_settings.log_settings.run_log_clean_up", + "frappe.utils.subscription.enable_manage_subscription", ], "daily_long": [ "frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_daily", diff --git a/frappe/integrations/doctype/google_calendar/google_calendar.py b/frappe/integrations/doctype/google_calendar/google_calendar.py index 09ed012454..534e3c1ac7 100644 --- a/frappe/integrations/doctype/google_calendar/google_calendar.py +++ b/frappe/integrations/doctype/google_calendar/google_calendar.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from urllib.parse import quote +from zoneinfo import ZoneInfo import google.oauth2.credentials import requests @@ -274,7 +275,7 @@ def sync_events_from_google_calendar(g_calendar, method=None): if err.resp.status == 410: set_encrypted_password("Google Calendar", account.name, "", "next_sync_token") frappe.db.commit() - msg += " " + _("Sync token was invalid and has been resetted, Retry syncing.") + msg += " " + _("Sync token was invalid and has been reset, Retry syncing.") frappe.msgprint(msg, title="Invalid Sync Token", indicator="blue") else: frappe.throw(msg) @@ -356,6 +357,7 @@ def insert_event_to_calendar(account, event, recurrence=None): "google_calendar": account.name, "google_calendar_id": account.google_calendar_id, "google_calendar_event_id": event.get("id"), + "google_meet_link": event.get("hangoutLink"), "pulled_from_google_calendar": 1, } calendar_event.update( @@ -373,6 +375,7 @@ def update_event_in_calendar(account, event, recurrence=None): calendar_event = frappe.get_doc("Event", {"google_calendar_event_id": event.get("id")}) calendar_event.subject = event.get("summary") calendar_event.description = event.get("description") + calendar_event.google_meet_link = event.get("hangoutLink") calendar_event.update( google_calendar_to_repeat_on( recurrence=recurrence, start=event.get("start"), end=event.get("end") @@ -407,11 +410,30 @@ def insert_event_in_google_calendar(doc, method=None): if doc.repeat_on: event.update({"recurrence": repeat_on_to_google_calendar_recurrence_rule(doc)}) + event.update({"attendees": get_attendees(doc)}) + + conference_data_version = 0 + + if doc.add_video_conferencing: + event.update({"conferenceData": get_conference_data(doc)}) + conference_data_version = 1 + try: - event = google_calendar.events().insert(calendarId=doc.google_calendar_id, body=event).execute() - frappe.db.set_value( - "Event", doc.name, "google_calendar_event_id", event.get("id"), update_modified=False + event = ( + google_calendar.events() + .insert( + calendarId=doc.google_calendar_id, body=event, conferenceDataVersion=conference_data_version + ) + .execute() ) + + frappe.db.set_value( + "Event", + doc.name, + {"google_calendar_event_id": event.get("id"), "google_meet_link": event.get("hangoutLink")}, + update_modified=False, + ) + frappe.msgprint(_("Event Synced with Google Calendar.")) except HttpError as err: frappe.throw( @@ -450,6 +472,7 @@ def update_event_in_google_calendar(doc, method=None): .get(calendarId=doc.google_calendar_id, eventId=doc.google_calendar_event_id) .execute() ) + event["summary"] = doc.subject event["description"] = doc.description event["recurrence"] = repeat_on_to_google_calendar_recurrence_rule(doc) @@ -462,9 +485,38 @@ def update_event_in_google_calendar(doc, method=None): ) ) - google_calendar.events().update( - calendarId=doc.google_calendar_id, eventId=doc.google_calendar_event_id, body=event - ).execute() + conference_data_version = 0 + + if doc.add_video_conferencing: + event.update({"conferenceData": get_conference_data(doc)}) + conference_data_version = 1 + elif doc.get_doc_before_save().add_video_conferencing or event.get("hangoutLink"): + # remove google meet from google calendar event, if turning off add_video_conferencing + event.update({"conferenceData": None}) + conference_data_version = 1 + + event.update({"attendees": get_attendees(doc)}) + + event = ( + google_calendar.events() + .update( + calendarId=doc.google_calendar_id, + eventId=doc.google_calendar_event_id, + body=event, + conferenceDataVersion=conference_data_version, + ) + .execute() + ) + + # if add_video_conferencing enabled or disabled during update, overwrite + frappe.db.set_value( + "Event", + doc.name, + {"google_meet_link": event.get("hangoutLink")}, + update_modified=False, + ) + doc.notify_update() + frappe.msgprint(_("Event Synced with Google Calendar.")) except HttpError as err: frappe.throw( @@ -515,12 +567,20 @@ def google_calendar_to_repeat_on(start, end, recurrence=None): Both have been mapped in a dict for easier mapping. """ repeat_on = { - "starts_on": get_datetime(start.get("date")) - if start.get("date") - else parser.parse(start.get("dateTime")).utcnow(), - "ends_on": get_datetime(end.get("date")) - if end.get("date") - else parser.parse(end.get("dateTime")).utcnow(), + "starts_on": ( + get_datetime(start.get("date")) + if start.get("date") + else parser.parse(start.get("dateTime")) + .astimezone(ZoneInfo(get_time_zone())) + .replace(tzinfo=None) + ), + "ends_on": ( + get_datetime(end.get("date")) + if end.get("date") + else parser.parse(end.get("dateTime")) + .astimezone(ZoneInfo(get_time_zone())) + .replace(tzinfo=None) + ), "all_day": 1 if start.get("date") else 0, "repeat_this_event": 1 if recurrence else 0, "repeat_on": None, @@ -682,6 +742,39 @@ def get_recurrence_parameters(recurrence): return frequency, until, byday +def get_conference_data(doc): + return { + "createRequest": {"requestId": doc.name, "conferenceSolutionKey": {"type": "hangoutsMeet"}}, + "notes": doc.description, + } + + +def get_attendees(doc): + """ + Returns a list of dicts with attendee emails, if available in event_participants table + """ + attendees, email_not_found = [], [] + + for participant in doc.event_participants: + if participant.get("email"): + attendees.append({"email": participant.email}) + else: + email_not_found.append( + {"dt": participant.reference_doctype, "dn": participant.reference_docname} + ) + + if email_not_found: + frappe.msgprint( + _("Google Calendar - Contact / email not found. Did not add attendee for -
    {0}").format( + "
    ".join(f"{d.get('dt')} {d.get('dn')}" for d in email_not_found) + ), + alert=True, + indicator="yellow", + ) + + return attendees + + """API Response { 'kind': 'calendar#events', @@ -721,6 +814,32 @@ def get_recurrence_parameters(recurrence): 'recurrence': *recurrence, 'iCalUID': 'uid', 'sequence': 1, + 'hangoutLink': 'https://meet.google.com/mee-ting-uri', + 'conferenceData': { + 'createRequest': { + 'requestId': 'EV00001', + 'conferenceSolutionKey': { + 'type': 'hangoutsMeet' + }, + 'status': { + 'statusCode': 'success' + } + }, + 'entryPoints': [ + { + 'entryPointType': 'video', + 'uri': 'https://meet.google.com/mee-ting-uri', + 'label': 'meet.google.com/mee-ting-uri' + } + ], + 'conferenceSolution': { + 'key': { + 'type': 'hangoutsMeet' + }, + 'name': 'Google Meet', + 'iconUri': 'https://fonts.gstatic.com/s/i/productlogos/meet_2020q4/v6/web-512dp/logo_meet_2020q4_color_2x_web_512dp.png' + }, + 'conferenceId': 'mee-ting-uri' 'reminders': { 'useDefault': True } diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 5b6caf4f6e..c6b3707b5c 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -152,8 +152,9 @@ class BaseDocument: if "name" in d: self.name = d["name"] + ignore_children = hasattr(self, "flags") and self.flags.ignore_children for key, value in d.items(): - self.set(key, value) + self.set(key, value, as_value=ignore_children) return self @@ -744,7 +745,7 @@ class BaseDocument: # don't cache if fetching other values too values = frappe.db.get_value(doctype, docname, values_to_fetch, as_dict=True) - if frappe.get_meta(doctype).issingle: + if getattr(frappe.get_meta(doctype), "issingle", 0): values.name = doctype if frappe.get_meta(doctype).get("is_virtual"): @@ -884,7 +885,7 @@ class BaseDocument: if frappe.flags.in_install: return - if self.meta.issingle: + if getattr(self.meta, "issingle", 0): # single doctype value type is mediumtext return @@ -1174,7 +1175,10 @@ class BaseDocument: # get values from old doc if self.get("parent_doc"): parent_doc = self.parent_doc.get_latest() - ref_doc = [d for d in parent_doc.get(self.parentfield) if d.name == self.name][0] + child_docs = [d for d in parent_doc.get(self.parentfield) if d.name == self.name] + if not child_docs: + return + ref_doc = child_docs[0] else: ref_doc = self.get_latest() diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 1ea9aae473..d54f1adbbc 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -635,7 +635,7 @@ class DatabaseQuery: f.value = date_range fallback = f"'{FallBackDateTimeStr}'" - if f.operator in (">", "<") and (f.fieldname in ("creation", "modified")): + if f.operator in (">", "<", ">=", "<=") and (f.fieldname in ("creation", "modified")): value = cstr(f.value) fallback = f"'{FallBackDateTimeStr}'" diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index 072b9a1d66..d1120cc22d 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -95,6 +95,10 @@ def delete_doc( update_flags(doc, flags, ignore_permissions) check_permission_and_not_submitted(doc) + # delete custom table fields using this doctype. + frappe.db.delete( + "Custom Field", {"options": name, "fieldtype": ("in", frappe.model.table_fields)} + ) frappe.db.delete("__global_search", {"doctype": name}) delete_from_table(doctype, name, ignore_doctypes, None) diff --git a/frappe/model/document.py b/frappe/model/document.py index aa55eac30a..936d9b049c 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -129,6 +129,7 @@ class Document(BaseDocument): def load_from_db(self): """Load document and children from database and create properties from fields""" + self.flags.ignore_children = True if not getattr(self, "_metaclass", False) and self.meta.issingle: single_doc = frappe.db.get_singles_dict(self.doctype, for_update=self.flags.for_update) if not single_doc: @@ -150,6 +151,7 @@ class Document(BaseDocument): ) super().__init__(d) + self.flags.pop("ignore_children", None) for df in self._get_table_fields(): # Make sure not to query the DB for a child table, if it is a virtual one. @@ -948,15 +950,19 @@ class Document(BaseDocument): from frappe.email.doctype.notification.notification import evaluate_alert if self.flags.notifications is None: - alerts = frappe.cache().hget("notifications", self.doctype) - if alerts is None: - alerts = frappe.get_all( + + def _get_notifications(): + """returns enabled notifications for the current doctype""" + + return frappe.get_all( "Notification", fields=["name", "event", "method"], filters={"enabled": 1, "document_type": self.doctype}, ) - frappe.cache().hset("notifications", self.doctype, alerts) - self.flags.notifications = alerts + + self.flags.notifications = frappe.cache().hget( + "notifications", self.doctype, _get_notifications + ) if not self.flags.notifications: return @@ -1172,6 +1178,9 @@ class Document(BaseDocument): # to trigger notification on value change self.run_method("before_change") + if self.name is None: + return + frappe.db.set_value( self.doctype, self.name, diff --git a/frappe/model/naming.py b/frappe/model/naming.py index 49a58da314..e5441dde76 100644 --- a/frappe/model/naming.py +++ b/frappe/model/naming.py @@ -165,16 +165,7 @@ def set_new_name(doc): if not doc.name and autoname: set_name_from_naming_options(autoname, doc) - # if the autoname option is 'field:' and no name was derived, we need to - # notify - if not doc.name and autoname.startswith("field:"): - fieldname = autoname[6:] - frappe.throw(_("{0} is required").format(doc.meta.get_label(fieldname))) - # at this point, we fall back to name generation with the hash option - if not doc.name and autoname == "hash": - doc.name = make_autoname("hash", doc.doctype) - if not doc.name: doc.name = make_autoname("hash", doc.doctype) @@ -220,6 +211,13 @@ def set_name_from_naming_options(autoname, doc): if _autoname.startswith("field:"): doc.name = _field_autoname(autoname, doc) + + # if the autoname option is 'field:' and no name was derived, we need to + # notify + if not doc.name: + fieldname = autoname[6:] + frappe.throw(_("{0} is required").format(doc.meta.get_label(fieldname))) + elif _autoname.startswith("naming_series:"): set_name_by_naming_series(doc) elif _autoname.startswith("prompt"): @@ -275,8 +273,8 @@ def make_autoname(key="", doctype="", doc=""): *Example:* - * DE/./.YY./.MM./.##### will create a series like - DE/09/01/0001 where 09 is the year, 01 is the month and 0001 is the series + * DE./.YY./.MM./.##### will create a series like + 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(doctype, 10) diff --git a/frappe/modules/patch_handler.py b/frappe/modules/patch_handler.py index d5a37f52a5..00d0e8167d 100644 --- a/frappe/modules/patch_handler.py +++ b/frappe/modules/patch_handler.py @@ -175,6 +175,7 @@ def execute_patch(patchmodule, method=None, methodargs=None): start_time = time.time() frappe.db.begin() + frappe.db.auto_commit_on_many_writes = 0 try: if patchmodule: if patchmodule.startswith("finally:"): diff --git a/frappe/modules/utils.py b/frappe/modules/utils.py index aa4ef6cb68..09a91f21fd 100644 --- a/frappe/modules/utils.py +++ b/frappe/modules/utils.py @@ -305,13 +305,16 @@ def make_boilerplate( def db_update(self): pass - def get_list(self, args): + @staticmethod + def get_list(args): pass - def get_count(self, args): + @staticmethod + def get_count(args): pass - def get_stats(self, args): + @staticmethod + def get_stats(args): pass """ ), diff --git a/frappe/monitor.py b/frappe/monitor.py index 8d5391cb77..8db1e25d32 100644 --- a/frappe/monitor.py +++ b/frappe/monitor.py @@ -25,6 +25,13 @@ def stop(response=None): frappe.local.monitor.dump(response) +def add_data_to_monitor(**kwargs) -> None: + """Add additional custom key-value pairs along with monitor log. + Note: Key-value pairs should be simple JSON exportable types.""" + if hasattr(frappe.local, "monitor"): + frappe.local.monitor.add_custom_data(**kwargs) + + def log_file(): return os.path.join(frappe.utils.get_bench_path(), "logs", "monitor.json.log") @@ -71,6 +78,10 @@ class Monitor: waitdiff = self.data.timestamp - job.enqueued_at self.data.job.wait = int(waitdiff.total_seconds() * 1000000) + def add_custom_data(self, **kwargs): + if self.data: + self.data.update(kwargs) + def dump(self, response=None): try: timediff = datetime.utcnow() - self.data.timestamp @@ -94,7 +105,7 @@ class Monitor: def store(self): if frappe.cache().llen(MONITOR_REDIS_KEY) > MONITOR_MAX_ENTRIES: frappe.cache().ltrim(MONITOR_REDIS_KEY, 1, -1) - serialized = json.dumps(self.data, sort_keys=True, default=str) + serialized = json.dumps(self.data, sort_keys=True, default=str, separators=(",", ":")) frappe.cache().rpush(MONITOR_REDIS_KEY, serialized) diff --git a/frappe/parallel_test_runner.py b/frappe/parallel_test_runner.py index 39a00235cb..905296c5f3 100644 --- a/frappe/parallel_test_runner.py +++ b/frappe/parallel_test_runner.py @@ -18,11 +18,12 @@ if click_ctx: class ParallelTestRunner: - def __init__(self, app, site, build_number=1, total_builds=1): + def __init__(self, app, site, build_number=1, total_builds=1, dry_run=False): self.app = app self.site = site self.build_number = frappe.utils.cint(build_number) or 1 self.total_builds = frappe.utils.cint(total_builds) + self.dry_run = dry_run self.setup_test_site() self.run_tests() @@ -31,6 +32,9 @@ class ParallelTestRunner: if not frappe.db: frappe.connect() + if self.dry_run: + return + frappe.flags.in_test = True frappe.clear_cache() frappe.utils.scheduler.disable_scheduler() @@ -64,6 +68,10 @@ class ParallelTestRunner: if not file_info: return + if self.dry_run: + print("running tests from", "/".join(file_info)) + return + frappe.set_user("Administrator") path, filename = file_info module = self.get_module(path, filename) @@ -108,12 +116,48 @@ class ParallelTestRunner: sys.exit(1) def get_test_file_list(self): + # Load balance based on total # of tests ~ each runner should get roughly same # of tests. test_list = get_all_tests(self.app) - split_size = frappe.utils.ceil(len(test_list) / self.total_builds) - # [1,2,3,4,5,6] to [[1,2], [3,4], [4,6]] if split_size is 2 - test_chunks = [test_list[x : x + split_size] for x in range(0, len(test_list), split_size)] + + test_counts = [self.get_test_count(test) for test in test_list] + test_chunks = split_by_weight(test_list, test_counts, chunk_count=self.total_builds) + return test_chunks[self.build_number - 1] + @staticmethod + def get_test_count(test): + """Get approximate count of tests inside a file""" + file_name = "/".join(test) + + with open(file_name) as f: + test_count = f.read().count("def test_") + + return test_count + + +def split_by_weight(work, weights, chunk_count): + """Roughly split work by respective weight while keep ordering.""" + expected_weight = sum(weights) // chunk_count + + chunks = [[] for _ in range(chunk_count)] + + chunk_no = 0 + chunk_weight = 0 + + for task, weight in zip(work, weights): + if chunk_weight > expected_weight: + chunk_weight = 0 + chunk_no += 1 + assert chunk_no < chunk_count + + chunks[chunk_no].append(task) + chunk_weight += weight + + assert len(work) == sum(len(chunk) for chunk in chunks) + assert len(chunks) == chunk_count + + return chunks + class ParallelTestResult(unittest.TextTestResult): def startTest(self, test): diff --git a/frappe/patches.txt b/frappe/patches.txt index 2564a565b1..278a351093 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -213,3 +213,5 @@ frappe.patches.v14_0.update_multistep_webforms execute:frappe.delete_doc('Page', 'background_jobs', ignore_missing=True, force=True) frappe.patches.v14_0.drop_unused_indexes frappe.patches.v15_0.drop_modified_index +frappe.patches.v14_0.add_manage_subscriptions_in_navbar_settings +frappe.patches.v14_0.update_attachment_comment diff --git a/frappe/patches/v14_0/add_manage_subscriptions_in_navbar_settings.py b/frappe/patches/v14_0/add_manage_subscriptions_in_navbar_settings.py new file mode 100644 index 0000000000..0c54eddc93 --- /dev/null +++ b/frappe/patches/v14_0/add_manage_subscriptions_in_navbar_settings.py @@ -0,0 +1,25 @@ +import frappe + + +def execute(): + navbar_settings = frappe.get_single("Navbar Settings") + + if frappe.db.exists("Navbar Item", {"item_label": "Manage Subscriptions"}): + return + + for idx, row in enumerate(navbar_settings.settings_dropdown[2:], start=4): + row.idx = idx + + navbar_settings.append( + "settings_dropdown", + { + "item_label": "Manage Subscriptions", + "item_type": "Action", + "action": "frappe.ui.toolbar.redirectToUrl()", + "is_standard": 1, + "hidden": 1, + "idx": 3, + }, + ) + + navbar_settings.save() diff --git a/frappe/patches/v14_0/update_attachment_comment.py b/frappe/patches/v14_0/update_attachment_comment.py new file mode 100644 index 0000000000..042579d86d --- /dev/null +++ b/frappe/patches/v14_0/update_attachment_comment.py @@ -0,0 +1,33 @@ +import frappe + + +def execute(): + frappe.db.auto_commit_on_many_writes = 1 + + # Strip everything except link to attachment and icon from comments of type "Attached" + for name, content in frappe.get_all( + "Comment", filters={"comment_type": "Attachment"}, fields=["name", "content"], as_list=True + ): + if not content: + continue + + start = content.find("") + end = content.find("") if end == -1 else end + if end != -1: + content = content[: end + 4] + + frappe.db.set_value("Comment", name, "content", content, update_modified=False) + + # Strip "Removed " from comments of type "Attachment Removed" + for name, content in frappe.get_all( + "Comment", + filters={"comment_type": "Attachment Removed"}, + fields=["name", "content"], + as_list=True, + ): + if content and content.startswith("Removed "): + frappe.db.set_value("Comment", name, "content", content[8:], update_modified=False) diff --git a/frappe/patches/v14_0/update_github_endpoints.py b/frappe/patches/v14_0/update_github_endpoints.py index 5ea638f0a6..f7bded4e96 100644 --- a/frappe/patches/v14_0/update_github_endpoints.py +++ b/frappe/patches/v14_0/update_github_endpoints.py @@ -1,6 +1,7 @@ -import frappe import json +import frappe + def execute(): if frappe.db.exists("Social Login Key", "github"): diff --git a/frappe/public/js/desk.bundle.js b/frappe/public/js/desk.bundle.js index 6697c034bc..3383c6aaeb 100644 --- a/frappe/public/js/desk.bundle.js +++ b/frappe/public/js/desk.bundle.js @@ -83,6 +83,7 @@ import "./frappe/ui/toolbar/search_utils.js"; import "./frappe/ui/toolbar/about.js"; import "./frappe/ui/toolbar/navbar.html"; import "./frappe/ui/toolbar/toolbar.js"; +import "./frappe/ui/toolbar/subscription.js"; // import "./frappe/ui/toolbar/notifications.js"; import "./frappe/views/communication.js"; import "./frappe/views/translation_manager.js"; diff --git a/frappe/public/js/frappe-web.bundle.js b/frappe/public/js/frappe-web.bundle.js index e2098dd56c..36064767fb 100644 --- a/frappe/public/js/frappe-web.bundle.js +++ b/frappe/public/js/frappe-web.bundle.js @@ -1,7 +1,6 @@ -import "./jquery-bootstrap"; +import "./libs.bundle.js"; import "./frappe/class.js"; import "./frappe/polyfill.js"; -import "./lib/moment.js"; import "./frappe/provide.js"; import "./frappe/translate.js"; import "./frappe/form/formatters.js"; diff --git a/frappe/public/js/frappe/build_events/BuildError.vue b/frappe/public/js/frappe/build_events/BuildError.vue index 6e10852719..13c0ce39a2 100644 --- a/frappe/public/js/frappe/build_events/BuildError.vue +++ b/frappe/public/js/frappe/build_events/BuildError.vue @@ -9,47 +9,46 @@ - - diff --git a/frappe/public/js/frappe/recorder/RecorderRoot.vue b/frappe/public/js/frappe/recorder/RecorderRoot.vue index 479ab1b2ca..0aa5a42469 100644 --- a/frappe/public/js/frappe/recorder/RecorderRoot.vue +++ b/frappe/public/js/frappe/recorder/RecorderRoot.vue @@ -1,17 +1,20 @@ - diff --git a/frappe/public/js/frappe/recorder/RequestDetail.vue b/frappe/public/js/frappe/recorder/RequestDetail.vue index 8ee6ff631b..002e69bbd4 100644 --- a/frappe/public/js/frappe/recorder/RequestDetail.vue +++ b/frappe/public/js/frappe/recorder/RequestDetail.vue @@ -53,7 +53,7 @@
    {{ __("Query") }}
    -
    {{ __("Duration (ms)") }}
    +
    {{ __("Duration (ms)") }}"
    {{ __("Exact Copies") }}
    @@ -101,7 +101,7 @@
    -
    +
    @@ -122,11 +122,9 @@ - + + {{ row[key] }} + @@ -192,112 +190,113 @@ - diff --git a/frappe/public/js/frappe/recorder/recorder.bundle.js b/frappe/public/js/frappe/recorder/recorder.bundle.js new file mode 100644 index 0000000000..4f6fb59d26 --- /dev/null +++ b/frappe/public/js/frappe/recorder/recorder.bundle.js @@ -0,0 +1,8 @@ +import { createApp } from "vue"; +import RecorderRoot from "./RecorderRoot.vue"; +import router from "./router.js"; + +let app = createApp(RecorderRoot).use(router); +SetVueGlobals(app); +app.mount(".recorder-container"); +frappe.recorder.view = app; diff --git a/frappe/public/js/frappe/recorder/recorder.js b/frappe/public/js/frappe/recorder/recorder.js deleted file mode 100644 index 850bfa393a..0000000000 --- a/frappe/public/js/frappe/recorder/recorder.js +++ /dev/null @@ -1,48 +0,0 @@ -import Vue from "vue/dist/vue.js"; -import VueRouter from "vue-router/dist/vue-router.js"; - -import RecorderRoot from "./RecorderRoot.vue"; - -import RecorderDetail from "./RecorderDetail.vue"; -import RequestDetail from "./RequestDetail.vue"; - -Vue.prototype.__ = window.__; -Vue.prototype.frappe = window.frappe; - -Vue.use(VueRouter); -const routes = [ - { - name: "recorder-detail", - path: "/detail", - component: RecorderDetail, - }, - { - name: "request-detail", - path: "/request/:id", - component: RequestDetail, - }, - { - path: "/", - redirect: { - name: "recorder-detail", - }, - }, -]; - -const router = new VueRouter({ - mode: "history", - base: "/app/recorder/", - routes: routes, -}); - -frappe.recorder.view = new Vue({ - el: ".recorder-container", - router: router, - data: { - page: frappe.pages["recorder"].page, - }, - template: "", - components: { - RecorderRoot, - }, -}); diff --git a/frappe/public/js/frappe/recorder/router.js b/frappe/public/js/frappe/recorder/router.js new file mode 100644 index 0000000000..ebff6eba7c --- /dev/null +++ b/frappe/public/js/frappe/recorder/router.js @@ -0,0 +1,28 @@ +import { createWebHistory, createRouter } from "vue-router"; +import RecorderDetail from "./RecorderDetail.vue"; +import RequestDetail from "./RequestDetail.vue"; + +const routes = [ + { + path: "/detail", + name: "RecorderDetail", + component: RecorderDetail, + }, + { + path: "/request/:id", + name: "RequestDetail", + component: RequestDetail, + meta: { shouldFetch: true }, + }, + { + path: "/", + redirect: "/detail", + }, +]; + +const router = createRouter({ + history: createWebHistory("/app/recorder/"), + routes, +}); + +export default router; diff --git a/frappe/public/js/frappe/router.js b/frappe/public/js/frappe/router.js index 0d1b815bce..c453fcad6e 100644 --- a/frappe/public/js/frappe/router.js +++ b/frappe/public/js/frappe/router.js @@ -89,6 +89,18 @@ frappe.router = { "image", "inbox", ], + list_views_route: { + list: "List", + kanban: "Kanban", + report: "Report", + calendar: "Calendar", + tree: "Tree", + gantt: "Gantt", + dashboard: "Dashboard", + image: "Image", + inbox: "Inbox", + file: "Home", + }, layout_mapped: {}, is_app_route(path) { @@ -115,7 +127,7 @@ frappe.router = { } }, - route() { + async route() { // resolve the route from the URL or hash // translate it so the objects are well defined // and render the page as required @@ -126,22 +138,22 @@ frappe.router = { if (this.re_route(sub_path)) return; this.current_sub_path = sub_path; - this.current_route = this.parse(); + this.current_route = await this.parse(); this.set_history(sub_path); this.render(); this.set_title(sub_path); this.trigger("change"); }, - parse(route) { + async parse(route) { route = this.get_sub_path_string(route).split("/"); if (!route) return []; route = $.map(route, this.decode_component); this.set_route_options_from_url(); - return this.convert_to_standard_route(route); + return await this.convert_to_standard_route(route); }, - convert_to_standard_route(route) { + async convert_to_standard_route(route) { // /app/settings = ["Workspaces", "Settings"] // /app/private/settings = ["Workspaces", "private", "Settings"] // /app/user = ["List", "User"] @@ -161,7 +173,7 @@ frappe.router = { route = ["Workspaces", "private", frappe.workspaces[private_workspace].title]; } else if (this.routes[route[0]]) { // route - route = this.set_doctype_route(route); + route = await this.set_doctype_route(route); } return route; @@ -174,40 +186,85 @@ frappe.router = { set_doctype_route(route) { let doctype_route = this.routes[route[0]]; - // doctype route - if (route[1]) { - if (route[2] && route[1] === "view") { - route = this.get_standard_route_for_list(route, doctype_route); - } else { + + return frappe.model.with_doctype(doctype_route.doctype).then(() => { + // doctype route + let meta = frappe.get_meta(doctype_route.doctype); + + if (route[1] && route[1] === "view" && route[2]) { + route = this.get_standard_route_for_list( + route, + doctype_route, + meta.force_re_route_to_default_view && meta.default_view + ? meta.default_view + : null + ); + } else if (route[1] && route[1] !== "view" && !route[2]) { let docname = route[1]; if (route.length > 2) { docname = route.slice(1).join("/"); } route = ["Form", doctype_route.doctype, docname]; + } else if (frappe.model.is_single(doctype_route.doctype)) { + route = ["Form", doctype_route.doctype, doctype_route.doctype]; + } else if (meta.default_view) { + route = [ + "List", + doctype_route.doctype, + this.list_views_route[meta.default_view.toLowerCase()], + ]; + } else { + route = ["List", doctype_route.doctype, "List"]; } - } else if (frappe.model.is_single(doctype_route.doctype)) { - route = ["Form", doctype_route.doctype, doctype_route.doctype]; - } else { - route = ["List", doctype_route.doctype, "List"]; - } - - if (doctype_route.doctype_layout) { - // set the layout + // reset the layout to avoid using incorrect views this.doctype_layout = doctype_route.doctype_layout; - } - - return route; + return route; + }); }, - get_standard_route_for_list(route, doctype_route) { + get_standard_route_for_list(route, doctype_route, default_view) { let standard_route; - if (route[2].toLowerCase() === "tree") { + let _route = default_view || route[2] || ""; + + if (_route.toLowerCase() === "tree") { standard_route = ["Tree", doctype_route.doctype]; } else { - standard_route = ["List", doctype_route.doctype, frappe.utils.to_title_case(route[2])]; + let new_route = this.list_views_route[_route.toLowerCase()]; + let re_route = route[2].toLowerCase() !== new_route.toLowerCase(); + + if (re_route) { + /** + * In case of force_re_route, the url of the route should change, + * if the _route and route[2] are different, it means there is a default_view + * with force_re_route enabled. + * + * To change the url, to the correct view, the route[2] is changed with default_view + * + * Eg: If default_view is set to Report with force_re_route enabled and user routes + * to List, + * route: [todo, view, list] + * default_view: report + * + * replaces the list to report and re-routes to the new route but should be replaced in + * the history since the list route should not exist in history as we are rerouting it to + * report + */ + frappe.route_flags.replace_route = true; + + route[2] = _route.toLowerCase(); + this.set_route(route); + } + + standard_route = [ + "List", + doctype_route.doctype, + this.list_views_route[_route.toLowerCase()], + ]; + // calendar / kanban / dashboard / folder if (route[3]) standard_route.push(...route.slice(3, route.length)); } + return standard_route; }, @@ -349,6 +406,7 @@ frappe.router = { } else if (view === "tree") { new_route = [this.slug(route[1]), "view", "tree"]; } + return new_route; }, diff --git a/frappe/public/js/frappe/socketio_client.js b/frappe/public/js/frappe/socketio_client.js index 6d01c19d42..9e290ede0b 100644 --- a/frappe/public/js/frappe/socketio_client.js +++ b/frappe/public/js/frappe/socketio_client.js @@ -42,16 +42,13 @@ frappe.socketio = { data.percent = (flt(data.progress[0]) / data.progress[1]) * 100; } if (data.percent) { - if (data.percent == 100) { - frappe.hide_progress(); - } else { - frappe.show_progress( - data.title || __("Progress"), - data.percent, - 100, - data.description - ); - } + frappe.show_progress( + data.title || __("Progress"), + data.percent, + 100, + data.description, + true + ); } }); diff --git a/frappe/public/js/frappe/ui/field_group.js b/frappe/public/js/frappe/ui/field_group.js index a7679e334b..5196eb52e6 100644 --- a/frappe/public/js/frappe/ui/field_group.js +++ b/frappe/public/js/frappe/ui/field_group.js @@ -169,6 +169,9 @@ frappe.ui.FieldGroup = class FieldGroup extends frappe.ui.form.Layout { } set_df_property(fieldname, prop, value) { + if (!fieldname) { + return; + } const field = this.get_field(fieldname); field.df[prop] = value; field.refresh(); diff --git a/frappe/public/js/frappe/ui/notifications/notifications.js b/frappe/public/js/frappe/ui/notifications/notifications.js index 69ca3b9a11..dab436acc7 100644 --- a/frappe/public/js/frappe/ui/notifications/notifications.js +++ b/frappe/public/js/frappe/ui/notifications/notifications.js @@ -327,12 +327,12 @@ class NotificationsView extends BaseNotificationsView { } get_item_link(notification_doc) { - const link_doctype = - notification_doc.type == "Alert" ? "Notification Log" : notification_doc.document_type; - const link_docname = - notification_doc.type == "Alert" - ? notification_doc.name - : notification_doc.document_name; + const link_doctype = notification_doc.document_type + ? notification_doc.document_type + : "Notification Log"; + const link_docname = notification_doc.document_name + ? notification_doc.document_name + : notification_doc.name; return frappe.utils.get_form_link(link_doctype, link_docname); } diff --git a/frappe/public/js/frappe/ui/toolbar/about.js b/frappe/public/js/frappe/ui/toolbar/about.js index 5a45440f7f..e23706eff1 100644 --- a/frappe/public/js/frappe/ui/toolbar/about.js +++ b/frappe/public/js/frappe/ui/toolbar/about.js @@ -5,26 +5,26 @@ frappe.ui.misc.about = function () { $(d.body).html( repl( - "
    \ -

    " + - __("Open Source Applications for the Web") + - "

    \ -

    \ - Website: https://frappeframework.com

    \ -

    \ - Source: https://github.com/frappe

    \ -

    \ - Linkedin: https://linkedin.com/company/frappe-tech

    \ -

    \ - Facebook: https://facebook.com/erpnext

    \ -

    \ - Twitter: https://twitter.com/erpnext

    \ -
    \ -

    Installed Apps

    \ -
    Loading versions...
    \ -
    \ -

    © Frappe Technologies Pvt. Ltd. and contributors

    \ -
    ", + `
    +

    ${__("Open Source Applications for the Web")}

    +

    + ${__("Website")}: + https://frappeframework.com

    +

    + ${__("Source")}: + https://github.com/frappe

    +

    + Linkedin: https://linkedin.com/company/frappe-tech

    +

    + Facebook: https://facebook.com/erpnext

    +

    + Twitter: https://twitter.com/erpnext

    +
    +

    ${__("Installed Apps")}

    +
    ${__("Loading versions...")}
    +
    +

    ${__("© Frappe Technologies Pvt. Ltd. and contributors")}

    +
    `, frappe.app ) ); diff --git a/frappe/public/js/frappe/ui/toolbar/search_utils.js b/frappe/public/js/frappe/ui/toolbar/search_utils.js index e3a34b2111..434fb888fc 100644 --- a/frappe/public/js/frappe/ui/toolbar/search_utils.js +++ b/frappe/public/js/frappe/ui/toolbar/search_utils.js @@ -208,13 +208,10 @@ frappe.search.utils = { }, }); } - if (in_list(frappe.boot.treeviews, item)) { - out.push(option("Tree", ["Tree", item], 0.05)); - } else { - out.push(option("List", ["List", item], 0.05)); - if (frappe.model.can_get_report(item)) { - out.push(option("Report", ["List", item, "Report"], 0.04)); - } + + out.push(option("List", ["List", item], 0.05)); + if (frappe.model.can_get_report(item)) { + out.push(option("Report", ["List", item, "Report"], 0.04)); } } } diff --git a/frappe/public/js/frappe/ui/toolbar/subscription.js b/frappe/public/js/frappe/ui/toolbar/subscription.js new file mode 100644 index 0000000000..cde855f989 --- /dev/null +++ b/frappe/public/js/frappe/ui/toolbar/subscription.js @@ -0,0 +1,80 @@ +$(document).on("startup", async () => { + if (!frappe.boot.setup_complete || !frappe.user.has_role("System Manager")) { + return; + } + + const expiry = frappe.boot.subscription_expiry; + + if (expiry) { + let diff_days = + frappe.datetime.get_day_diff(cstr(expiry), frappe.datetime.get_today()) - 1; + + let subscription_string = __( + `Your subscription will end in ${cstr(diff_days).bold()} ${ + diff_days > 1 ? "days" : "day" + }. After that your site will be suspended.` + ); + + let $bar = $(` +
    +
    +

    ${subscription_string}

    +
    + + +
    +
    +
    + `); + + $("footer").append($bar); + + $bar.find(".dismiss-upgrade").on("click", () => { + $bar.remove(); + }); + + $bar.find(".button-renew").on("click", () => { + redirectToUrl(); + }); + } +}); + +function redirectToUrl() { + frappe.call({ + method: "frappe.utils.subscription.remote_login", + callback: (url) => { + if (url.message !== false) { + window.open(url.message, "_blank"); + } else { + frappe.msgprint({ + title: __("Message"), + indicator: "orange", + message: __("No active subscriptions found."), + }); + } + }, + }); +} + +$.extend(frappe.ui.toolbar, { + redirectToUrl() { + redirectToUrl(); + }, +}); diff --git a/frappe/public/js/frappe/upload.js b/frappe/public/js/frappe/upload.js index 6adcffa99f..caaa44fe0c 100644 --- a/frappe/public/js/frappe/upload.js +++ b/frappe/public/js/frappe/upload.js @@ -1,7 +1,10 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // MIT License. See license.txt -import FileUploader from "./file_uploader"; - -frappe.provide("frappe.ui"); -frappe.ui.FileUploader = FileUploader; +if (frappe.require) { + frappe.require("file_uploader.bundle.js"); +} else { + frappe.ready(function () { + frappe.require("file_uploader.bundle.js"); + }); +} diff --git a/frappe/public/js/frappe/utils/dashboard_utils.js b/frappe/public/js/frappe/utils/dashboard_utils.js index eb009ef76a..488aa20742 100644 --- a/frappe/public/js/frappe/utils/dashboard_utils.js +++ b/frappe/public/js/frappe/utils/dashboard_utils.js @@ -15,7 +15,7 @@ frappe.dashboard_utils = { let chart_filter_html = `