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/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 33c83bf5e2..3ae8a35454 100644
--- a/.github/workflows/ui-tests.yml
+++ b/.github/workflows/ui-tests.yml
@@ -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
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/workspace_blocks.js b/cypress/integration/workspace_blocks.js
index 47c5424bce..2db646f38e 100644
--- a/cypress/integration/workspace_blocks.js
+++ b/cypress/integration/workspace_blocks.js
@@ -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/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/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/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..5e92d2dcaa 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 57849e5cfc..ea90b24a6f 100644
--- a/frappe/core/doctype/data_import/importer.py
+++ b/frappe/core/doctype/data_import/importer.py
@@ -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
diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json
index 78d2a43c75..b051408362 100644
--- a/frappe/core/doctype/doctype/doctype.json
+++ b/frappe/core/doctype/doctype/doctype.json
@@ -321,7 +321,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",
@@ -695,7 +696,7 @@
"link_fieldname": "reference_doctype"
}
],
- "modified": "2022-10-06 15:20:12.186038",
+ "modified": "2022-09-02 12:05:59.589751",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",
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/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/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/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 5c60088bc9..8e6a066db1 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
@@ -858,7 +857,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}
@@ -880,19 +879,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 5726b760ee..2e1635101a 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
@@ -14,6 +15,7 @@ from frappe.model.db_query import get_timespan_date_range
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.data import MARIADB_SPECIFIC_COMMENT
if TYPE_CHECKING:
from frappe.query_builder import DocType
@@ -492,7 +494,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:
@@ -506,12 +509,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 set_fields(self, table, fields, **kwargs) -> list:
+ 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 = []
@@ -535,39 +589,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)):
@@ -599,15 +623,17 @@ class Engine:
has_join = True
if has_join:
- for idx, field in enumerate(fields):
+
+ 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)):
- fields[idx] = getattr(frappe.qb.DocType(table), field)
+ return getattr(frappe.qb.DocType(table), field)
else:
field.args = [getattr(frappe.qb.DocType(table), arg.get_sql()) for arg in field.args]
- field.args[0] = getattr(frappe.qb.DocType(table), field.args[0].get_sql())
- fields[idx] = field
+ return field
+
+ fields = [_update_pypika_fields(field) for field in fields]
if len(self.tables) > 1:
primary_table = self.tables.pop(table)
@@ -631,7 +657,7 @@ class Engine:
self.linked_doctype = None
self.fieldname = None
- fields = self.set_fields(table, kwargs.get("field_objects") or fields, **kwargs)
+ fields = self.set_fields(kwargs.get("field_objects") or fields, **kwargs)
criterion = self.build_conditions(table, filters, **kwargs)
join = kwargs.get("join").replace(" ", "_") if kwargs.get("join") else "left_join"
criterion, fields = self.join_(criterion=criterion, fields=fields, table=table, join=join)
diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py
index fddf4f1120..8d2f1df973 100644
--- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py
+++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py
@@ -293,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/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/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/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..9f9aca1123 100644
--- a/frappe/integrations/doctype/google_calendar/google_calendar.py
+++ b/frappe/integrations/doctype/google_calendar/google_calendar.py
@@ -517,10 +517,10 @@ def google_calendar_to_repeat_on(start, end, recurrence=None):
repeat_on = {
"starts_on": get_datetime(start.get("date"))
if start.get("date")
- else parser.parse(start.get("dateTime")).utcnow(),
+ else parser.parse(start.get("dateTime")).astimezone().replace(tzinfo=None),
"ends_on": get_datetime(end.get("date"))
if end.get("date")
- else parser.parse(end.get("dateTime")).utcnow(),
+ else parser.parse(end.get("dateTime")).astimezone().replace(tzinfo=None),
"all_day": 1 if start.get("date") else 0,
"repeat_this_event": 1 if recurrence else 0,
"repeat_on": None,
diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py
index 3cdc2e4c1d..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
@@ -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/document.py b/frappe/model/document.py
index 8e6fe8cf84..8d503e5235 100644
--- a/frappe/model/document.py
+++ b/frappe/model/document.py
@@ -133,6 +133,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:
@@ -154,6 +155,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.
diff --git a/frappe/model/naming.py b/frappe/model/naming.py
index 4eca50ea97..e5441dde76 100644
--- a/frappe/model/naming.py
+++ b/frappe/model/naming.py
@@ -273,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/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..24b07012da 100644
--- a/frappe/patches.txt
+++ b/frappe/patches.txt
@@ -213,3 +213,4 @@ 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
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_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..fe09b82377 100644
--- a/frappe/public/js/frappe/router.js
+++ b/frappe/public/js/frappe/router.js
@@ -190,12 +190,8 @@ frappe.router = {
} else {
route = ["List", doctype_route.doctype, "List"];
}
-
- if (doctype_route.doctype_layout) {
- // set the layout
- this.doctype_layout = doctype_route.doctype_layout;
- }
-
+ // reset the layout to avoid using incorrect views
+ this.doctype_layout = doctype_route.doctype_layout;
return 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/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/datetime.js b/frappe/public/js/frappe/utils/datetime.js
index f50b52e1ae..44ce1bb44d 100644
--- a/frappe/public/js/frappe/utils/datetime.js
+++ b/frappe/public/js/frappe/utils/datetime.js
@@ -211,17 +211,21 @@ $.extend(frappe.datetime, {
return frappe.datetime._date(frappe.defaultDatetimeFormat, as_obj);
},
- _date: function (format, as_obj = false) {
- /**
- * Whenever we are getting now_date/datetime, always make sure dates are fetched using user time zone.
- * This is to make sure that time is as per user time zone set in User doctype, If a user had to change the timezone,
- * we will end up having multiple timezone by not honouring timezone in User doctype.
- * This will make sure that at any point we know which timezone the user if following and not have random timezone
- * when the timezone of the local machine changes.
- */
- let time_zone = frappe.boot.time_zone
- ? frappe.boot.time_zone.user || frappe.boot.time_zone.system
- : frappe.sys_defaults.time_zone;
+ system_datetime: function (as_obj = false) {
+ return frappe.datetime._date(frappe.defaultDatetimeFormat, as_obj, true);
+ },
+
+ _date: function (format, as_obj = false, system_time = false) {
+ let time_zone = frappe.boot.time_zone?.system || frappe.sys_defaults.time_zone;
+
+ // Whenever we are getting now_date/datetime, always make sure dates are fetched using user time zone.
+ // This is to make sure that time is as per user time zone set in User doctype, If a user had to change the timezone,
+ // we will end up having multiple timezone by not honouring timezone in User doctype.
+ // This will make sure that at any point we know which timezone the user if following and not have random timezone
+ // when the timezone of the local machine changes.
+ if (!system_time) {
+ time_zone = frappe.boot.time_zone?.user || time_zone;
+ }
let date = moment.tz(time_zone);
return as_obj ? frappe.datetime.moment_to_date_obj(date) : date.format(format);
diff --git a/frappe/public/js/frappe/utils/pretty_date.js b/frappe/public/js/frappe/utils/pretty_date.js
index 9c1a44efd6..0e04ad70d2 100644
--- a/frappe/public/js/frappe/utils/pretty_date.js
+++ b/frappe/public/js/frappe/utils/pretty_date.js
@@ -11,7 +11,9 @@ function prettyDate(date, mini) {
);
}
- let diff = (new Date(frappe.datetime.now_datetime()).getTime() - date.getTime()) / 1000;
+ let diff =
+ (new Date(frappe.datetime.now_datetime().replace(/-/g, "/")).getTime() - date.getTime()) /
+ 1000;
let day_diff = Math.floor(diff / 86400);
if (isNaN(day_diff) || day_diff < 0) return "";
diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js
index ee13d31d9f..12c6e12697 100644
--- a/frappe/public/js/frappe/utils/utils.js
+++ b/frappe/public/js/frappe/utils/utils.js
@@ -3,6 +3,7 @@
import deep_equal from "fast-deep-equal";
import number_systems from "./number_systems";
+import cloneDeepWith from "lodash/cloneDeepWith";
frappe.provide("frappe.utils");
@@ -1000,6 +1001,10 @@ Object.assign(frappe.utils, {
return deep_equal(a, b);
},
+ deep_clone(obj, customizer) {
+ return cloneDeepWith(obj, customizer);
+ },
+
file_name_ellipsis(filename, length) {
let first_part_length = (length * 2) / 3;
let last_part_length = length - first_part_length;
diff --git a/frappe/public/js/frappe/views/kanban/kanban_board.bundle.js b/frappe/public/js/frappe/views/kanban/kanban_board.bundle.js
index 7845311f60..11e7fba198 100644
--- a/frappe/public/js/frappe/views/kanban/kanban_board.bundle.js
+++ b/frappe/public/js/frappe/views/kanban/kanban_board.bundle.js
@@ -1,6 +1,6 @@
// TODO: Refactor for better UX
-import Vuex from "vuex";
+import { createStore } from "vuex";
frappe.provide("frappe.views");
@@ -9,7 +9,7 @@ frappe.provide("frappe.views");
let columns_unwatcher = null;
- var store = new Vuex.Store({
+ var store = createStore({
state: {
doctype: "",
board: {},
diff --git a/frappe/public/js/frappe/views/modules_home.js b/frappe/public/js/frappe/views/modules_home.js
deleted file mode 100644
index 03226957cd..0000000000
--- a/frappe/public/js/frappe/views/modules_home.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import Modules from "./components/Modules.vue";
-
-frappe.provide("frappe.modules");
-
-frappe.modules.Home = class {
- constructor({ parent }) {
- this.$parent = $(parent);
- this.page = parent.page;
- this.setup_header();
- this.make_body();
- }
- make_body() {
- this.$modules_container = this.$parent.find(".layout-main");
-
- new Vue({
- el: this.$modules_container[0],
- render: (h) => h(Modules),
- });
- }
- setup_header() {
- // subtitle
- }
-};
diff --git a/frappe/public/js/frappe/widgets/chart_widget.js b/frappe/public/js/frappe/widgets/chart_widget.js
index 6e6d2e6f75..18f7459a6c 100644
--- a/frappe/public/js/frappe/widgets/chart_widget.js
+++ b/frappe/public/js/frappe/widgets/chart_widget.js
@@ -571,12 +571,13 @@ export default class ChartWidget extends Widget {
Heatmap: "heatmap",
};
+ let max_slices = ["Pie", "Donut"].includes(this.chart_doc.type) ? 6 : 9;
let chart_args = {
data: this.data,
type: chart_type_map[this.chart_doc.type],
colors: colors,
height: this.height,
- maxSlices: ["Pie", "Donut"].includes(this.chart_doc.type) ? 6 : 9,
+ maxSlices: this.chart_doc.number_of_groups || max_slices,
axisOptions: {
xIsSeries: this.chart_doc.timeseries,
shortenYAxisNumbers: 1,
diff --git a/frappe/public/js/libs.bundle.js b/frappe/public/js/libs.bundle.js
index 3d3738fd88..e4e172c1b4 100644
--- a/frappe/public/js/libs.bundle.js
+++ b/frappe/public/js/libs.bundle.js
@@ -1,7 +1,9 @@
import "./jquery-bootstrap";
-import Vue from "vue/dist/vue.esm.js";
import "./lib/moment";
import Sortable from "sortablejs";
-window.Vue = Vue;
+window.SetVueGlobals = (app) => {
+ app.config.globalProperties.__ = window.__;
+ app.config.globalProperties.frappe = window.frappe;
+};
window.Sortable = Sortable;
diff --git a/frappe/public/js/print_format_builder/ConfigureColumns.vue b/frappe/public/js/print_format_builder/ConfigureColumns.vue
index a236a119b9..14d011e409 100644
--- a/frappe/public/js/print_format_builder/ConfigureColumns.vue
+++ b/frappe/public/js/print_format_builder/ConfigureColumns.vue
@@ -18,78 +18,74 @@
:group="df.fieldname"
handle=".icon-drag"
>
-