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:
${getChangedMessage(
+ addedFields
+ )}
`;
+ }
+
+ if (removedFields.length) {
+ message += `The following fields have been removed:
${getChangedMessage(
+ removedFields
+ )}
`;
+ }
+
+ 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] }} |
-
-
+
+ | {{ 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(
- "",
+ ``,
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 = `