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:

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

    `; + } + + if (message) { + frappe.msgprint({ + message: __(message), + indicator: "green", + title: __("Synced Fields"), + }); + } } }, }); diff --git a/frappe/custom/doctype/doctype_layout/doctype_layout.json b/frappe/custom/doctype/doctype_layout/doctype_layout.json index e47c9e03e0..0b627f78ce 100644 --- a/frappe/custom/doctype/doctype_layout/doctype_layout.json +++ b/frappe/custom/doctype/doctype_layout/doctype_layout.json @@ -1,7 +1,7 @@ { "actions": [], "allow_rename": 1, - "autoname": "Prompt", + "autoname": "prompt", "creation": "2020-11-16 17:05:35.306846", "doctype": "DocType", "editable_grid": 1, @@ -19,7 +19,8 @@ "in_list_view": 1, "label": "Document Type", "options": "DocType", - "reqd": 1 + "reqd": 1, + "set_only_once": 1 }, { "fieldname": "fields", @@ -42,10 +43,11 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2020-12-10 15:01:04.352184", + "modified": "2022-09-01 03:22:33.973058", "modified_by": "Administrator", "module": "Custom", "name": "DocType Layout", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { @@ -68,5 +70,6 @@ "route": "doctype-layout", "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/custom/doctype/doctype_layout/doctype_layout.py b/frappe/custom/doctype/doctype_layout/doctype_layout.py index ea8e9acc99..f712853ccd 100644 --- a/frappe/custom/doctype/doctype_layout/doctype_layout.py +++ b/frappe/custom/doctype/doctype_layout/doctype_layout.py @@ -1,11 +1,77 @@ # Copyright (c) 2020, Frappe Technologies and contributors # License: MIT. See LICENSE +from typing import TYPE_CHECKING + +import frappe from frappe.desk.utils import slug from frappe.model.document import Document +if TYPE_CHECKING: + from frappe.core.doctype.docfield.docfield import DocField + class DocTypeLayout(Document): def validate(self): if not self.route: self.route = slug(self.name) + + @frappe.whitelist() + def sync_fields(self): + doctype_fields = frappe.get_meta(self.document_type).fields + + if self.is_new(): + added_fields = [field.fieldname for field in doctype_fields] + removed_fields = [] + else: + doctype_fieldnames = {field.fieldname for field in doctype_fields} + layout_fieldnames = {field.fieldname for field in self.fields} + added_fields = list(doctype_fieldnames - layout_fieldnames) + removed_fields = list(layout_fieldnames - doctype_fieldnames) + + if not (added_fields or removed_fields): + return + + added = self.add_fields(added_fields, doctype_fields) + removed = self.remove_fields(removed_fields) + + for index, field in enumerate(self.fields): + field.idx = index + 1 + + return {"added": added, "removed": removed} + + def add_fields(self, added_fields: list[str], doctype_fields: list["DocField"]) -> list[dict]: + added = [] + for field in added_fields: + field_details = next((f for f in doctype_fields if f.fieldname == field), None) + if not field_details: + continue + + # remove 'doctype' data from the DocField to allow adding it to the layout + row = self.append("fields", field_details.as_dict(no_default_fields=True)) + row_data = row.as_dict() + + if field_details.get("insert_after"): + insert_after = next( + (f for f in self.fields if f.fieldname == field_details.insert_after), + None, + ) + + # initialize new row to just after the insert_after field + if insert_after: + self.fields.insert(insert_after.idx, row) + self.fields.pop() + + row_data = {"idx": insert_after.idx + 1, "fieldname": row.fieldname, "label": row.label} + + added.append(row_data) + return added + + def remove_fields(self, removed_fields: list[str]) -> list[dict]: + removed = [] + for field in removed_fields: + field_details = next((f for f in self.fields if f.fieldname == field), None) + if field_details: + self.remove(field_details) + removed.append(field_details.as_dict()) + return removed diff --git a/frappe/database/database.py b/frappe/database/database.py index 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] }} + @@ -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" > -
    -
    -
    -
    - - - -
    -
    - +
    - +